Report the window size, open the portal chooser, and colour emoji
A client that lays columns out by arithmetic needs the window's size, and glimmer-cosmic never said: frq sized its message list against a width of zero and the people panel took half the window. cosmic_window_width and _height report what libcosmic last resized to. cosmic_pick_image opens the desktop's own file chooser through the XDG portal on libcosmic's executor, and cosmic_picked_image hands the choice back as a PNG at the caller's path, the shape frq already polls for. Emoji and reaction glyphs name Noto Color Emoji, since fallback found a monochrome smiley first. Rows centre their children, boxes default to the theme's spacing, and text breaks inside a word that cannot fit, so a URL stays in its column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
71cdc87 parent: 7703c75 modified
Cargo.lock +1 -0 | @@ -4849,6 +4849,7 @@ dependencies = [ | ||
| 4849 | 4849 | "jolt-abi", |
| 4850 | 4850 | "libcosmic", |
| 4851 | 4851 | "log", |
| 4852 | + "rfd", | |
| 4852 | 4853 | ] |
| 4853 | 4854 | |
| 4854 | 4855 | [[package]] |
| @@ -4849,6 +4849,7 @@ dependencies = [ | |||
| 4849 | "jolt-abi", | 4849 | "jolt-abi", |
| 4850 | "libcosmic", | 4850 | "libcosmic", |
| 4851 | "log", | 4851 | "log", |
| 4852 | + "rfd", | ||
| 4852 | ] | 4853 | ] |
| 4853 | 4854 | ||
| 4854 | [[package]] | 4855 | [[package]] |
modified
crates/jolt-cosmic/Cargo.toml +5 -0 | @@ -28,6 +28,11 @@ log.workspace = true | ||
| 28 | 28 | # naming the features here is enough. |
| 29 | 29 | image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } |
| 30 | 30 | |
| 31 | +# The desktop's own file chooser, through the XDG portal — the version | |
| 32 | +# libcosmic's optional `rfd` feature is locked to, on the tokio runtime its | |
| 33 | +# executor already runs. | |
| 34 | +rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } | |
| 35 | + | |
| 31 | 36 | # Pinned to the rev the COSMIC applet in wayland-bar already builds against. |
| 32 | 37 | [dependencies.libcosmic] |
| 33 | 38 | git = "https://github.com/pop-os/libcosmic" |
| @@ -28,6 +28,11 @@ log.workspace = true | |||
| 28 | # naming the features here is enough. | 28 | # naming the features here is enough. |
| 29 | image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } | 29 | image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } |
| 30 | 30 | ||
| 31 | +# The desktop's own file chooser, through the XDG portal — the version | ||
| 32 | +# libcosmic's optional `rfd` feature is locked to, on the tokio runtime its | ||
| 33 | +# executor already runs. | ||
| 34 | +rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } | ||
| 35 | + | ||
| 31 | # Pinned to the rev the COSMIC applet in wayland-bar already builds against. | 36 | # Pinned to the rev the COSMIC applet in wayland-bar already builds against. |
| 32 | [dependencies.libcosmic] | 37 | [dependencies.libcosmic] |
| 33 | git = "https://github.com/pop-os/libcosmic" | 38 | git = "https://github.com/pop-os/libcosmic" |
modified
crates/jolt-cosmic/src/lib.rs +140 -13 | @@ -25,7 +25,8 @@ pub use tree::{Node, Prop, Tree}; | ||
| 25 | 25 | |
| 26 | 26 | use std::collections::{HashMap, HashSet, VecDeque}; |
| 27 | 27 | use std::ffi::{c_char, c_int}; |
| 28 | -use std::sync::atomic::{AtomicBool, Ordering::SeqCst}; | |
| 28 | +use std::path::PathBuf; | |
| 29 | +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering::SeqCst}; | |
| 29 | 30 | use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; |
| 30 | 31 | use std::time::Duration; |
| 31 | 32 | |
| @@ -34,7 +35,8 @@ use cosmic::iced::futures::channel::mpsc; | ||
| 34 | 35 | use cosmic::iced::futures::{Stream, StreamExt}; |
| 35 | 36 | use cosmic::iced::widget::container::Style as ContainerStyle; |
| 36 | 37 | use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport}; |
| 37 | -use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Length, Padding, Subscription}; | |
| 38 | +use cosmic::iced::widget::text::Wrapping; | |
| 39 | +use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription}; | |
| 38 | 40 | use cosmic::widget::{self, Column, Row}; |
| 39 | 41 | use cosmic::{ApplicationExt, Element}; |
| 40 | 42 | use jolt_abi::{borrowed, empty_str, guard, Scratch}; |
| @@ -108,6 +110,7 @@ fn post(node: i32, name: &'static str, text: String, num: f64) { | ||
| 108 | 110 | enum Wake { |
| 109 | 111 | Tree, |
| 110 | 112 | Quit, |
| 113 | + PickImage, | |
| 111 | 114 | } |
| 112 | 115 | |
| 113 | 116 | static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None); |
| @@ -115,6 +118,25 @@ static QUIT_ASKED: AtomicBool = AtomicBool::new(false); | ||
| 115 | 118 | static RAN: AtomicBool = AtomicBool::new(false); |
| 116 | 119 | static CLOSED: AtomicBool = AtomicBool::new(false); |
| 117 | 120 | |
| 121 | +/// The window's size in points, as libcosmic last reported it. A client that | |
| 122 | +/// lays columns out by arithmetic — frq sizes its message list against the | |
| 123 | +/// people panel beside it — has to be able to ask. | |
| 124 | +static WINDOW_W: AtomicU32 = AtomicU32::new(0); | |
| 125 | +static WINDOW_H: AtomicU32 = AtomicU32::new(0); | |
| 126 | + | |
| 127 | +/// Where a picture chooser opened by `cosmic_pick_image` has got to. | |
| 128 | +enum Pick { | |
| 129 | + Idle, | |
| 130 | + Open, | |
| 131 | + Chosen(PathBuf), | |
| 132 | +} | |
| 133 | + | |
| 134 | +static PICK: Mutex<Pick> = Mutex::new(Pick::Idle); | |
| 135 | + | |
| 136 | +/// Named for emoji rather than left to fallback: the first face with a glyph | |
| 137 | +/// for a smiley is often a monochrome one, and the pill then shows an outline. | |
| 138 | +const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji"); | |
| 139 | + | |
| 118 | 140 | fn tell_app(wake: Wake) { |
| 119 | 141 | if let Some(tx) = lock(&TO_APP).as_ref() { |
| 120 | 142 | let _ = tx.unbounded_send(wake); |
| @@ -134,6 +156,7 @@ fn wakes() -> impl Stream<Item = Message> { | ||
| 134 | 156 | rx.map(|wake| match wake { |
| 135 | 157 | Wake::Tree => Message::Tree, |
| 136 | 158 | Wake::Quit => Message::Quit, |
| 159 | + Wake::PickImage => Message::PickImage, | |
| 137 | 160 | }) |
| 138 | 161 | } |
| 139 | 162 | |
| @@ -261,6 +284,8 @@ enum Message { | ||
| 261 | 284 | Hover(i32), |
| 262 | 285 | Unhover(i32), |
| 263 | 286 | Scrolled(i32, String, Viewport), |
| 287 | + PickImage, | |
| 288 | + Picked(Option<PathBuf>), | |
| 264 | 289 | } |
| 265 | 290 | |
| 266 | 291 | impl App { |
| @@ -368,6 +393,11 @@ impl cosmic::Application for App { | ||
| 368 | 393 | Subscription::run(wakes) |
| 369 | 394 | } |
| 370 | 395 | |
| 396 | + fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) { | |
| 397 | + WINDOW_W.store(width.max(0.0) as u32, SeqCst); | |
| 398 | + WINDOW_H.store(height.max(0.0) as u32, SeqCst); | |
| 399 | + } | |
| 400 | + | |
| 371 | 401 | fn update(&mut self, message: Message) -> Task<Message> { |
| 372 | 402 | match message { |
| 373 | 403 | Message::Tree => return self.take_tree(), |
| @@ -385,6 +415,23 @@ impl cosmic::Application for App { | ||
| 385 | 415 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), |
| 386 | 416 | Message::Unhover(node) => post(node, "unhover", String::new(), 0.0), |
| 387 | 417 | Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport), |
| 418 | + // The desktop's own chooser, through the portal, on libcosmic's | |
| 419 | + // executor: it is a D-Bus round trip, and the window keeps | |
| 420 | + // painting while it is open. | |
| 421 | + Message::PickImage => { | |
| 422 | + return Task::perform( | |
| 423 | + async { | |
| 424 | + rfd::AsyncFileDialog::new() | |
| 425 | + .set_title("Choose a picture") | |
| 426 | + .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"]) | |
| 427 | + .pick_file() | |
| 428 | + .await | |
| 429 | + .map(|file| file.path().to_path_buf()) | |
| 430 | + }, | |
| 431 | + |path| cosmic::Action::App(Message::Picked(path)), | |
| 432 | + ); | |
| 433 | + } | |
| 434 | + Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen), | |
| 388 | 435 | } |
| 389 | 436 | Task::none() |
| 390 | 437 | } |
| @@ -416,11 +463,15 @@ fn width_request(n: &Node) -> Option<f32> { | ||
| 416 | 463 | n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32) |
| 417 | 464 | } |
| 418 | 465 | |
| 419 | -fn alignment(n: &Node) -> Alignment { | |
| 466 | +/// `align`, or `default` where it is not set. A row centres its children on | |
| 467 | +/// the cross axis by default — a label beside a button otherwise sits against | |
| 468 | +/// the top of the button — and a column starts them at the left. | |
| 469 | +fn alignment(n: &Node, default: Alignment) -> Alignment { | |
| 420 | 470 | match n.str("align") { |
| 471 | + "start" => Alignment::Start, | |
| 421 | 472 | "center" => Alignment::Center, |
| 422 | 473 | "end" => Alignment::End, |
| 423 | - _ => Alignment::Start, | |
| 474 | + _ => default, | |
| 424 | 475 | } |
| 425 | 476 | } |
| 426 | 477 | |
| @@ -487,7 +538,9 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 487 | 538 | }; |
| 488 | 539 | let enabled = enabled && n.bool("sensitive") != Some(false); |
| 489 | 540 | let fill_height = n.bool("fill-height") == Some(true); |
| 490 | - let spacing = n.num("spacing").unwrap_or(0.0) as f32; | |
| 541 | + // glimmer-jvui's theme spacing, where the client does not say: a list of | |
| 542 | + // cards with nothing between them reads as one slab. | |
| 543 | + let spacing = n.num("spacing").unwrap_or(6.0) as f32; | |
| 491 | 544 | let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row)); |
| 492 | 545 | |
| 493 | 546 | let el: Element<'_, Message> = match n.tag.as_str() { |
| @@ -506,7 +559,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 506 | 559 | let mut row = Row::with_children(children(true)) |
| 507 | 560 | .spacing(spacing) |
| 508 | 561 | .padding(margins(n)) |
| 509 | - .align_y(alignment(n)); | |
| 562 | + .align_y(alignment(n, Alignment::Center)); | |
| 510 | 563 | // A row fills the width it is in only when it or something in |
| 511 | 564 | // it asks to; otherwise a line of buttons would spread out. |
| 512 | 565 | match width_request(n) { |
| @@ -522,7 +575,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 522 | 575 | let mut column = Column::with_children(children(false)) |
| 523 | 576 | .spacing(spacing) |
| 524 | 577 | .padding(margins(n)) |
| 525 | - .align_x(alignment(n)); | |
| 578 | + .align_x(alignment(n, Alignment::Start)); | |
| 526 | 579 | match width_request(n) { |
| 527 | 580 | Some(w) => column = column.width(w), |
| 528 | 581 | None if fill_height || !in_row => column = column.width(Length::Fill), |
| @@ -579,11 +632,23 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 579 | 632 | .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport)) |
| 580 | 633 | .into() |
| 581 | 634 | } |
| 582 | - "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()).into(), | |
| 583 | - "label" => widget::text::body(n.label()).into(), | |
| 584 | - "title" => widget::text::title3(n.label()).into(), | |
| 585 | - "title-2" => widget::text::title4(n.label()).into(), | |
| 586 | - "dim-label" => widget::text::caption(n.label()).into(), | |
| 635 | + // Word wrapping that falls back to breaking inside a word: a URL is one | |
| 636 | + // word, and it otherwise runs straight past the edge of its column. | |
| 637 | + "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()) | |
| 638 | + .wrapping(Wrapping::WordOrGlyph) | |
| 639 | + .into(), | |
| 640 | + "label" => widget::text::body(n.label()) | |
| 641 | + .wrapping(Wrapping::WordOrGlyph) | |
| 642 | + .into(), | |
| 643 | + "title" => widget::text::title3(n.label()) | |
| 644 | + .wrapping(Wrapping::WordOrGlyph) | |
| 645 | + .into(), | |
| 646 | + "title-2" => widget::text::title4(n.label()) | |
| 647 | + .wrapping(Wrapping::WordOrGlyph) | |
| 648 | + .into(), | |
| 649 | + "dim-label" => widget::text::caption(n.label()) | |
| 650 | + .wrapping(Wrapping::WordOrGlyph) | |
| 651 | + .into(), | |
| 587 | 652 | "button" => { |
| 588 | 653 | let button = match n.str("kind") { |
| 589 | 654 | "primary" => widget::button::suggested(n.label()), |
| @@ -629,6 +694,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 629 | 694 | }; |
| 630 | 695 | widget::text(glyph.to_owned()) |
| 631 | 696 | .size(n.num("size").unwrap_or(16.0) as f32) |
| 697 | + .font(EMOJI_FONT) | |
| 632 | 698 | .into() |
| 633 | 699 | } |
| 634 | 700 | // A round picture, or the initial on a colour from the name: most |
| @@ -669,7 +735,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 669 | 735 | let mut content = Row::new() |
| 670 | 736 | .spacing(4) |
| 671 | 737 | .align_y(Alignment::Center) |
| 672 | - .push(widget::text(glyph.to_owned()).size(size)); | |
| 738 | + .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT)); | |
| 673 | 739 | let count = n.num("count").unwrap_or(0.0); |
| 674 | 740 | if count > 0.0 { |
| 675 | 741 | content = content.push(widget::text::caption(format!("{count}"))); |
| @@ -817,6 +883,9 @@ pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int | ||
| 817 | 883 | log::error!("jolt-cosmic: a window already ran in this process"); |
| 818 | 884 | return 2; |
| 819 | 885 | } |
| 886 | + // The size asked for, until libcosmic reports the one it got. | |
| 887 | + WINDOW_W.store(width.max(1) as u32, SeqCst); | |
| 888 | + WINDOW_H.store(height.max(1) as u32, SeqCst); | |
| 820 | 889 | let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32); |
| 821 | 890 | let mut settings = cosmic::app::Settings::default().size(size); |
| 822 | 891 | match mode { |
| @@ -897,6 +966,64 @@ pub extern "C" fn cosmic_wake() { | ||
| 897 | 966 | }) |
| 898 | 967 | } |
| 899 | 968 | |
| 969 | +// --- the C ABI: the window and the desktop ----------------------------------------- | |
| 970 | + | |
| 971 | +/// The window's width in points; the size asked for until it has opened. | |
| 972 | +#[no_mangle] | |
| 973 | +pub extern "C" fn cosmic_window_width() -> c_int { | |
| 974 | + WINDOW_W.load(SeqCst) as c_int | |
| 975 | +} | |
| 976 | + | |
| 977 | +#[no_mangle] | |
| 978 | +pub extern "C" fn cosmic_window_height() -> c_int { | |
| 979 | + WINDOW_H.load(SeqCst) as c_int | |
| 980 | +} | |
| 981 | + | |
| 982 | +/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when | |
| 983 | +/// there is no window to ask from; the choice arrives through | |
| 984 | +/// `cosmic_picked_image`. | |
| 985 | +#[no_mangle] | |
| 986 | +pub extern "C" fn cosmic_pick_image() -> c_int { | |
| 987 | + guard(0, || { | |
| 988 | + if lock(&TO_APP).is_none() { | |
| 989 | + return 0; | |
| 990 | + } | |
| 991 | + *lock(&PICK) = Pick::Open; | |
| 992 | + tell_app(Wake::PickImage); | |
| 993 | + 1 | |
| 994 | + }) | |
| 995 | +} | |
| 996 | + | |
| 997 | +/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture | |
| 998 | +/// was chosen since the last call; 0 while the chooser is open, after it was | |
| 999 | +/// cancelled, or when the picture could not be read. | |
| 1000 | +/// | |
| 1001 | +/// # Safety | |
| 1002 | +/// `path` is null or a NUL-terminated string. | |
| 1003 | +#[no_mangle] | |
| 1004 | +pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int { | |
| 1005 | + let path = borrowed(path); | |
| 1006 | + guard(0, || { | |
| 1007 | + let chosen = { | |
| 1008 | + let mut pick = lock(&PICK); | |
| 1009 | + match std::mem::replace(&mut *pick, Pick::Idle) { | |
| 1010 | + Pick::Chosen(chosen) => chosen, | |
| 1011 | + other => { | |
| 1012 | + *pick = other; | |
| 1013 | + return 0; | |
| 1014 | + } | |
| 1015 | + } | |
| 1016 | + }; | |
| 1017 | + match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) { | |
| 1018 | + Ok(()) => 1, | |
| 1019 | + Err(err) => { | |
| 1020 | + eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display()); | |
| 1021 | + 0 | |
| 1022 | + } | |
| 1023 | + } | |
| 1024 | + }) | |
| 1025 | +} | |
| 1026 | + | |
| 900 | 1027 | // --- the C ABI: events ----------------------------------------------------------- |
| 901 | 1028 | |
| 902 | 1029 | static EVENT_NAME: Scratch = Scratch::new(); |
| @@ -25,7 +25,8 @@ pub use tree::{Node, Prop, Tree}; | |||
| 25 | 25 | ||
| 26 | use std::collections::{HashMap, HashSet, VecDeque}; | 26 | use std::collections::{HashMap, HashSet, VecDeque}; |
| 27 | use std::ffi::{c_char, c_int}; | 27 | use std::ffi::{c_char, c_int}; |
| 28 | -use std::sync::atomic::{AtomicBool, Ordering::SeqCst}; | 28 | +use std::path::PathBuf; |
| 29 | +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering::SeqCst}; | ||
| 29 | use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; | 30 | use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; |
| 30 | use std::time::Duration; | 31 | use std::time::Duration; |
| 31 | 32 | ||
| @@ -34,7 +35,8 @@ use cosmic::iced::futures::channel::mpsc; | |||
| 34 | use cosmic::iced::futures::{Stream, StreamExt}; | 35 | use cosmic::iced::futures::{Stream, StreamExt}; |
| 35 | use cosmic::iced::widget::container::Style as ContainerStyle; | 36 | use cosmic::iced::widget::container::Style as ContainerStyle; |
| 36 | use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport}; | 37 | use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport}; |
| 37 | -use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Length, Padding, Subscription}; | 38 | +use cosmic::iced::widget::text::Wrapping; |
| 39 | +use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription}; | ||
| 38 | use cosmic::widget::{self, Column, Row}; | 40 | use cosmic::widget::{self, Column, Row}; |
| 39 | use cosmic::{ApplicationExt, Element}; | 41 | use cosmic::{ApplicationExt, Element}; |
| 40 | use jolt_abi::{borrowed, empty_str, guard, Scratch}; | 42 | use jolt_abi::{borrowed, empty_str, guard, Scratch}; |
| @@ -108,6 +110,7 @@ fn post(node: i32, name: &'static str, text: String, num: f64) { | |||
| 108 | enum Wake { | 110 | enum Wake { |
| 109 | Tree, | 111 | Tree, |
| 110 | Quit, | 112 | Quit, |
| 113 | + PickImage, | ||
| 111 | } | 114 | } |
| 112 | 115 | ||
| 113 | static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None); | 116 | static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None); |
| @@ -115,6 +118,25 @@ static QUIT_ASKED: AtomicBool = AtomicBool::new(false); | |||
| 115 | static RAN: AtomicBool = AtomicBool::new(false); | 118 | static RAN: AtomicBool = AtomicBool::new(false); |
| 116 | static CLOSED: AtomicBool = AtomicBool::new(false); | 119 | static CLOSED: AtomicBool = AtomicBool::new(false); |
| 117 | 120 | ||
| 121 | +/// The window's size in points, as libcosmic last reported it. A client that | ||
| 122 | +/// lays columns out by arithmetic — frq sizes its message list against the | ||
| 123 | +/// people panel beside it — has to be able to ask. | ||
| 124 | +static WINDOW_W: AtomicU32 = AtomicU32::new(0); | ||
| 125 | +static WINDOW_H: AtomicU32 = AtomicU32::new(0); | ||
| 126 | + | ||
| 127 | +/// Where a picture chooser opened by `cosmic_pick_image` has got to. | ||
| 128 | +enum Pick { | ||
| 129 | + Idle, | ||
| 130 | + Open, | ||
| 131 | + Chosen(PathBuf), | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +static PICK: Mutex<Pick> = Mutex::new(Pick::Idle); | ||
| 135 | + | ||
| 136 | +/// Named for emoji rather than left to fallback: the first face with a glyph | ||
| 137 | +/// for a smiley is often a monochrome one, and the pill then shows an outline. | ||
| 138 | +const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji"); | ||
| 139 | + | ||
| 118 | fn tell_app(wake: Wake) { | 140 | fn tell_app(wake: Wake) { |
| 119 | if let Some(tx) = lock(&TO_APP).as_ref() { | 141 | if let Some(tx) = lock(&TO_APP).as_ref() { |
| 120 | let _ = tx.unbounded_send(wake); | 142 | let _ = tx.unbounded_send(wake); |
| @@ -134,6 +156,7 @@ fn wakes() -> impl Stream<Item = Message> { | |||
| 134 | rx.map(|wake| match wake { | 156 | rx.map(|wake| match wake { |
| 135 | Wake::Tree => Message::Tree, | 157 | Wake::Tree => Message::Tree, |
| 136 | Wake::Quit => Message::Quit, | 158 | Wake::Quit => Message::Quit, |
| 159 | + Wake::PickImage => Message::PickImage, | ||
| 137 | }) | 160 | }) |
| 138 | } | 161 | } |
| 139 | 162 | ||
| @@ -261,6 +284,8 @@ enum Message { | |||
| 261 | Hover(i32), | 284 | Hover(i32), |
| 262 | Unhover(i32), | 285 | Unhover(i32), |
| 263 | Scrolled(i32, String, Viewport), | 286 | Scrolled(i32, String, Viewport), |
| 287 | + PickImage, | ||
| 288 | + Picked(Option<PathBuf>), | ||
| 264 | } | 289 | } |
| 265 | 290 | ||
| 266 | impl App { | 291 | impl App { |
| @@ -368,6 +393,11 @@ impl cosmic::Application for App { | |||
| 368 | Subscription::run(wakes) | 393 | Subscription::run(wakes) |
| 369 | } | 394 | } |
| 370 | 395 | ||
| 396 | + fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) { | ||
| 397 | + WINDOW_W.store(width.max(0.0) as u32, SeqCst); | ||
| 398 | + WINDOW_H.store(height.max(0.0) as u32, SeqCst); | ||
| 399 | + } | ||
| 400 | + | ||
| 371 | fn update(&mut self, message: Message) -> Task<Message> { | 401 | fn update(&mut self, message: Message) -> Task<Message> { |
| 372 | match message { | 402 | match message { |
| 373 | Message::Tree => return self.take_tree(), | 403 | Message::Tree => return self.take_tree(), |
| @@ -385,6 +415,23 @@ impl cosmic::Application for App { | |||
| 385 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), | 415 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), |
| 386 | Message::Unhover(node) => post(node, "unhover", String::new(), 0.0), | 416 | Message::Unhover(node) => post(node, "unhover", String::new(), 0.0), |
| 387 | Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport), | 417 | Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport), |
| 418 | + // The desktop's own chooser, through the portal, on libcosmic's | ||
| 419 | + // executor: it is a D-Bus round trip, and the window keeps | ||
| 420 | + // painting while it is open. | ||
| 421 | + Message::PickImage => { | ||
| 422 | + return Task::perform( | ||
| 423 | + async { | ||
| 424 | + rfd::AsyncFileDialog::new() | ||
| 425 | + .set_title("Choose a picture") | ||
| 426 | + .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"]) | ||
| 427 | + .pick_file() | ||
| 428 | + .await | ||
| 429 | + .map(|file| file.path().to_path_buf()) | ||
| 430 | + }, | ||
| 431 | + |path| cosmic::Action::App(Message::Picked(path)), | ||
| 432 | + ); | ||
| 433 | + } | ||
| 434 | + Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen), | ||
| 388 | } | 435 | } |
| 389 | Task::none() | 436 | Task::none() |
| 390 | } | 437 | } |
| @@ -416,11 +463,15 @@ fn width_request(n: &Node) -> Option<f32> { | |||
| 416 | n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32) | 463 | n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32) |
| 417 | } | 464 | } |
| 418 | 465 | ||
| 419 | -fn alignment(n: &Node) -> Alignment { | 466 | +/// `align`, or `default` where it is not set. A row centres its children on |
| 467 | +/// the cross axis by default — a label beside a button otherwise sits against | ||
| 468 | +/// the top of the button — and a column starts them at the left. | ||
| 469 | +fn alignment(n: &Node, default: Alignment) -> Alignment { | ||
| 420 | match n.str("align") { | 470 | match n.str("align") { |
| 471 | + "start" => Alignment::Start, | ||
| 421 | "center" => Alignment::Center, | 472 | "center" => Alignment::Center, |
| 422 | "end" => Alignment::End, | 473 | "end" => Alignment::End, |
| 423 | - _ => Alignment::Start, | 474 | + _ => default, |
| 424 | } | 475 | } |
| 425 | } | 476 | } |
| 426 | 477 | ||
| @@ -487,7 +538,9 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 487 | }; | 538 | }; |
| 488 | let enabled = enabled && n.bool("sensitive") != Some(false); | 539 | let enabled = enabled && n.bool("sensitive") != Some(false); |
| 489 | let fill_height = n.bool("fill-height") == Some(true); | 540 | let fill_height = n.bool("fill-height") == Some(true); |
| 490 | - let spacing = n.num("spacing").unwrap_or(0.0) as f32; | 541 | + // glimmer-jvui's theme spacing, where the client does not say: a list of |
| 542 | + // cards with nothing between them reads as one slab. | ||
| 543 | + let spacing = n.num("spacing").unwrap_or(6.0) as f32; | ||
| 491 | let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row)); | 544 | let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row)); |
| 492 | 545 | ||
| 493 | let el: Element<'_, Message> = match n.tag.as_str() { | 546 | let el: Element<'_, Message> = match n.tag.as_str() { |
| @@ -506,7 +559,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 506 | let mut row = Row::with_children(children(true)) | 559 | let mut row = Row::with_children(children(true)) |
| 507 | .spacing(spacing) | 560 | .spacing(spacing) |
| 508 | .padding(margins(n)) | 561 | .padding(margins(n)) |
| 509 | - .align_y(alignment(n)); | 562 | + .align_y(alignment(n, Alignment::Center)); |
| 510 | // A row fills the width it is in only when it or something in | 563 | // A row fills the width it is in only when it or something in |
| 511 | // it asks to; otherwise a line of buttons would spread out. | 564 | // it asks to; otherwise a line of buttons would spread out. |
| 512 | match width_request(n) { | 565 | match width_request(n) { |
| @@ -522,7 +575,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 522 | let mut column = Column::with_children(children(false)) | 575 | let mut column = Column::with_children(children(false)) |
| 523 | .spacing(spacing) | 576 | .spacing(spacing) |
| 524 | .padding(margins(n)) | 577 | .padding(margins(n)) |
| 525 | - .align_x(alignment(n)); | 578 | + .align_x(alignment(n, Alignment::Start)); |
| 526 | match width_request(n) { | 579 | match width_request(n) { |
| 527 | Some(w) => column = column.width(w), | 580 | Some(w) => column = column.width(w), |
| 528 | None if fill_height || !in_row => column = column.width(Length::Fill), | 581 | None if fill_height || !in_row => column = column.width(Length::Fill), |
| @@ -579,11 +632,23 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 579 | .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport)) | 632 | .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport)) |
| 580 | .into() | 633 | .into() |
| 581 | } | 634 | } |
| 582 | - "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()).into(), | 635 | + // Word wrapping that falls back to breaking inside a word: a URL is one |
| 583 | - "label" => widget::text::body(n.label()).into(), | 636 | + // word, and it otherwise runs straight past the edge of its column. |
| 584 | - "title" => widget::text::title3(n.label()).into(), | 637 | + "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()) |
| 585 | - "title-2" => widget::text::title4(n.label()).into(), | 638 | + .wrapping(Wrapping::WordOrGlyph) |
| 586 | - "dim-label" => widget::text::caption(n.label()).into(), | 639 | + .into(), |
| 640 | + "label" => widget::text::body(n.label()) | ||
| 641 | + .wrapping(Wrapping::WordOrGlyph) | ||
| 642 | + .into(), | ||
| 643 | + "title" => widget::text::title3(n.label()) | ||
| 644 | + .wrapping(Wrapping::WordOrGlyph) | ||
| 645 | + .into(), | ||
| 646 | + "title-2" => widget::text::title4(n.label()) | ||
| 647 | + .wrapping(Wrapping::WordOrGlyph) | ||
| 648 | + .into(), | ||
| 649 | + "dim-label" => widget::text::caption(n.label()) | ||
| 650 | + .wrapping(Wrapping::WordOrGlyph) | ||
| 651 | + .into(), | ||
| 587 | "button" => { | 652 | "button" => { |
| 588 | let button = match n.str("kind") { | 653 | let button = match n.str("kind") { |
| 589 | "primary" => widget::button::suggested(n.label()), | 654 | "primary" => widget::button::suggested(n.label()), |
| @@ -629,6 +694,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 629 | }; | 694 | }; |
| 630 | widget::text(glyph.to_owned()) | 695 | widget::text(glyph.to_owned()) |
| 631 | .size(n.num("size").unwrap_or(16.0) as f32) | 696 | .size(n.num("size").unwrap_or(16.0) as f32) |
| 697 | + .font(EMOJI_FONT) | ||
| 632 | .into() | 698 | .into() |
| 633 | } | 699 | } |
| 634 | // A round picture, or the initial on a colour from the name: most | 700 | // A round picture, or the initial on a colour from the name: most |
| @@ -669,7 +735,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 669 | let mut content = Row::new() | 735 | let mut content = Row::new() |
| 670 | .spacing(4) | 736 | .spacing(4) |
| 671 | .align_y(Alignment::Center) | 737 | .align_y(Alignment::Center) |
| 672 | - .push(widget::text(glyph.to_owned()).size(size)); | 738 | + .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT)); |
| 673 | let count = n.num("count").unwrap_or(0.0); | 739 | let count = n.num("count").unwrap_or(0.0); |
| 674 | if count > 0.0 { | 740 | if count > 0.0 { |
| 675 | content = content.push(widget::text::caption(format!("{count}"))); | 741 | content = content.push(widget::text::caption(format!("{count}"))); |
| @@ -817,6 +883,9 @@ pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int | |||
| 817 | log::error!("jolt-cosmic: a window already ran in this process"); | 883 | log::error!("jolt-cosmic: a window already ran in this process"); |
| 818 | return 2; | 884 | return 2; |
| 819 | } | 885 | } |
| 886 | + // The size asked for, until libcosmic reports the one it got. | ||
| 887 | + WINDOW_W.store(width.max(1) as u32, SeqCst); | ||
| 888 | + WINDOW_H.store(height.max(1) as u32, SeqCst); | ||
| 820 | let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32); | 889 | let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32); |
| 821 | let mut settings = cosmic::app::Settings::default().size(size); | 890 | let mut settings = cosmic::app::Settings::default().size(size); |
| 822 | match mode { | 891 | match mode { |
| @@ -897,6 +966,64 @@ pub extern "C" fn cosmic_wake() { | |||
| 897 | }) | 966 | }) |
| 898 | } | 967 | } |
| 899 | 968 | ||
| 969 | +// --- the C ABI: the window and the desktop ----------------------------------------- | ||
| 970 | + | ||
| 971 | +/// The window's width in points; the size asked for until it has opened. | ||
| 972 | +#[no_mangle] | ||
| 973 | +pub extern "C" fn cosmic_window_width() -> c_int { | ||
| 974 | + WINDOW_W.load(SeqCst) as c_int | ||
| 975 | +} | ||
| 976 | + | ||
| 977 | +#[no_mangle] | ||
| 978 | +pub extern "C" fn cosmic_window_height() -> c_int { | ||
| 979 | + WINDOW_H.load(SeqCst) as c_int | ||
| 980 | +} | ||
| 981 | + | ||
| 982 | +/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when | ||
| 983 | +/// there is no window to ask from; the choice arrives through | ||
| 984 | +/// `cosmic_picked_image`. | ||
| 985 | +#[no_mangle] | ||
| 986 | +pub extern "C" fn cosmic_pick_image() -> c_int { | ||
| 987 | + guard(0, || { | ||
| 988 | + if lock(&TO_APP).is_none() { | ||
| 989 | + return 0; | ||
| 990 | + } | ||
| 991 | + *lock(&PICK) = Pick::Open; | ||
| 992 | + tell_app(Wake::PickImage); | ||
| 993 | + 1 | ||
| 994 | + }) | ||
| 995 | +} | ||
| 996 | + | ||
| 997 | +/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture | ||
| 998 | +/// was chosen since the last call; 0 while the chooser is open, after it was | ||
| 999 | +/// cancelled, or when the picture could not be read. | ||
| 1000 | +/// | ||
| 1001 | +/// # Safety | ||
| 1002 | +/// `path` is null or a NUL-terminated string. | ||
| 1003 | +#[no_mangle] | ||
| 1004 | +pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int { | ||
| 1005 | + let path = borrowed(path); | ||
| 1006 | + guard(0, || { | ||
| 1007 | + let chosen = { | ||
| 1008 | + let mut pick = lock(&PICK); | ||
| 1009 | + match std::mem::replace(&mut *pick, Pick::Idle) { | ||
| 1010 | + Pick::Chosen(chosen) => chosen, | ||
| 1011 | + other => { | ||
| 1012 | + *pick = other; | ||
| 1013 | + return 0; | ||
| 1014 | + } | ||
| 1015 | + } | ||
| 1016 | + }; | ||
| 1017 | + match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) { | ||
| 1018 | + Ok(()) => 1, | ||
| 1019 | + Err(err) => { | ||
| 1020 | + eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display()); | ||
| 1021 | + 0 | ||
| 1022 | + } | ||
| 1023 | + } | ||
| 1024 | + }) | ||
| 1025 | +} | ||
| 1026 | + | ||
| 900 | // --- the C ABI: events ----------------------------------------------------------- | 1027 | // --- the C ABI: events ----------------------------------------------------------- |
| 901 | 1028 | ||
| 902 | static EVENT_NAME: Scratch = Scratch::new(); | 1029 | static EVENT_NAME: Scratch = Scratch::new(); |
modified
glimmer-backends/glimmer-cosmic/src/glimmer_cosmic/core.clj +19 -0 | @@ -272,6 +272,25 @@ | ||
| 272 | 272 | ([node] (println (dump-str node)) nil)) |
| 273 | 273 | |
| 274 | 274 | ;; --- the backend ------------------------------------------------------------- |
| 275 | +;; --- the window and the desktop ------------------------------------------------ | |
| 276 | +(defn window-size | |
| 277 | + "The window's size in points, [width height]: the size asked for until it | |
| 278 | + has opened, and what libcosmic reports after that." | |
| 279 | + [] | |
| 280 | + [(ffi/window-width) (ffi/window-height)]) | |
| 281 | + | |
| 282 | +(defn pick-image! | |
| 283 | + "Open the desktop's picture chooser. True when it was asked for; the choice | |
| 284 | + is collected with `picked-image!`, since the chooser answers when the person | |
| 285 | + using it does." | |
| 286 | + [] | |
| 287 | + (ffi/pick-image!)) | |
| 288 | + | |
| 289 | +(defn picked-image! | |
| 290 | + "Write the chosen picture to `path` as PNG. True once, when one was chosen." | |
| 291 | + [path] | |
| 292 | + (ffi/picked-image! path)) | |
| 293 | + | |
| 275 | 294 | (def backend |
| 276 | 295 | {:name :cosmic |
| 277 | 296 | :create! create! |
| @@ -272,6 +272,25 @@ | |||
| 272 | ([node] (println (dump-str node)) nil)) | 272 | ([node] (println (dump-str node)) nil)) |
| 273 | 273 | ||
| 274 | ;; --- the backend ------------------------------------------------------------- | 274 | ;; --- the backend ------------------------------------------------------------- |
| 275 | +;; --- the window and the desktop ------------------------------------------------ | ||
| 276 | +(defn window-size | ||
| 277 | + "The window's size in points, [width height]: the size asked for until it | ||
| 278 | + has opened, and what libcosmic reports after that." | ||
| 279 | + [] | ||
| 280 | + [(ffi/window-width) (ffi/window-height)]) | ||
| 281 | + | ||
| 282 | +(defn pick-image! | ||
| 283 | + "Open the desktop's picture chooser. True when it was asked for; the choice | ||
| 284 | + is collected with `picked-image!`, since the chooser answers when the person | ||
| 285 | + using it does." | ||
| 286 | + [] | ||
| 287 | + (ffi/pick-image!)) | ||
| 288 | + | ||
| 289 | +(defn picked-image! | ||
| 290 | + "Write the chosen picture to `path` as PNG. True once, when one was chosen." | ||
| 291 | + [path] | ||
| 292 | + (ffi/picked-image! path)) | ||
| 293 | + | ||
| 275 | (def backend | 294 | (def backend |
| 276 | {:name :cosmic | 295 | {:name :cosmic |
| 277 | :create! create! | 296 | :create! create! |
modified
glimmer-backends/glimmer-cosmic/src/glimmer_cosmic/ffi.clj +8 -0 | @@ -54,6 +54,12 @@ | ||
| 54 | 54 | (ffi/defcfn event-text "cosmic_tree_event_text" [] :string) |
| 55 | 55 | (ffi/defcfn event-num "cosmic_tree_event_num" [] :double) |
| 56 | 56 | |
| 57 | +;; --- the window and the desktop ------------------------------------------------ | |
| 58 | +(ffi/defcfn window-width "cosmic_window_width" [] :int) | |
| 59 | +(ffi/defcfn window-height "cosmic_window_height" [] :int) | |
| 60 | +(ffi/defcfn raw-pick-image "cosmic_pick_image" [] :int) | |
| 61 | +(ffi/defcfn raw-picked-image "cosmic_picked_image" [:string] :int) | |
| 62 | + | |
| 57 | 63 | ;; --- the int/bool seam ------------------------------------------------------- |
| 58 | 64 | (def system-mode 0) |
| 59 | 65 | (def dark-mode 1) |
| @@ -82,3 +88,5 @@ | ||
| 82 | 88 | (not (zero? (raw-node-insert-after parent child sibling)))) |
| 83 | 89 | (defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new)))) |
| 84 | 90 | (defn poll-event! [] (not (zero? (raw-poll-event)))) |
| 91 | +(defn pick-image! [] (not (zero? (raw-pick-image)))) | |
| 92 | +(defn picked-image! [path] (not (zero? (raw-picked-image path)))) | |
| @@ -54,6 +54,12 @@ | |||
| 54 | (ffi/defcfn event-text "cosmic_tree_event_text" [] :string) | 54 | (ffi/defcfn event-text "cosmic_tree_event_text" [] :string) |
| 55 | (ffi/defcfn event-num "cosmic_tree_event_num" [] :double) | 55 | (ffi/defcfn event-num "cosmic_tree_event_num" [] :double) |
| 56 | 56 | ||
| 57 | +;; --- the window and the desktop ------------------------------------------------ | ||
| 58 | +(ffi/defcfn window-width "cosmic_window_width" [] :int) | ||
| 59 | +(ffi/defcfn window-height "cosmic_window_height" [] :int) | ||
| 60 | +(ffi/defcfn raw-pick-image "cosmic_pick_image" [] :int) | ||
| 61 | +(ffi/defcfn raw-picked-image "cosmic_picked_image" [:string] :int) | ||
| 62 | + | ||
| 57 | ;; --- the int/bool seam ------------------------------------------------------- | 63 | ;; --- the int/bool seam ------------------------------------------------------- |
| 58 | (def system-mode 0) | 64 | (def system-mode 0) |
| 59 | (def dark-mode 1) | 65 | (def dark-mode 1) |
| @@ -82,3 +88,5 @@ | |||
| 82 | (not (zero? (raw-node-insert-after parent child sibling)))) | 88 | (not (zero? (raw-node-insert-after parent child sibling)))) |
| 83 | (defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new)))) | 89 | (defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new)))) |
| 84 | (defn poll-event! [] (not (zero? (raw-poll-event)))) | 90 | (defn poll-event! [] (not (zero? (raw-poll-event)))) |
| 91 | +(defn pick-image! [] (not (zero? (raw-pick-image)))) | ||
| 92 | +(defn picked-image! [path] (not (zero? (raw-picked-image path)))) | ||