nandi/jolt-nativepublic Fork 0
71cdc87
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-11T03:19:30-07:00 Browse files
71cdc87 parent: 7703c75
modified Cargo.lock +1 -0
@@ -4849,6 +4849,7 @@ dependencies = [
48494849 "jolt-abi",
48504850 "libcosmic",
48514851 "log",
4852+ "rfd",
48524853 ]
48534854
48544855 [[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
2828 # naming the features here is enough.
2929 image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
3030
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+
3136 # Pinned to the rev the COSMIC applet in wayland-bar already builds against.
3237 [dependencies.libcosmic]
3338 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};
2525
2626 use std::collections::{HashMap, HashSet, VecDeque};
2727 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};
2930 use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
3031 use std::time::Duration;
3132
@@ -34,7 +35,8 @@ use cosmic::iced::futures::channel::mpsc;
3435 use cosmic::iced::futures::{Stream, StreamExt};
3536 use cosmic::iced::widget::container::Style as ContainerStyle;
3637 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};
3840 use cosmic::widget::{self, Column, Row};
3941 use cosmic::{ApplicationExt, Element};
4042 use jolt_abi::{borrowed, empty_str, guard, Scratch};
@@ -108,6 +110,7 @@ fn post(node: i32, name: &'static str, text: String, num: f64) {
108110 enum Wake {
109111 Tree,
110112 Quit,
113+ PickImage,
111114 }
112115
113116 static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None);
@@ -115,6 +118,25 @@ static QUIT_ASKED: AtomicBool = AtomicBool::new(false);
115118 static RAN: AtomicBool = AtomicBool::new(false);
116119 static CLOSED: AtomicBool = AtomicBool::new(false);
117120
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+
118140 fn tell_app(wake: Wake) {
119141 if let Some(tx) = lock(&TO_APP).as_ref() {
120142 let _ = tx.unbounded_send(wake);
@@ -134,6 +156,7 @@ fn wakes() -> impl Stream<Item = Message> {
134156 rx.map(|wake| match wake {
135157 Wake::Tree => Message::Tree,
136158 Wake::Quit => Message::Quit,
159+ Wake::PickImage => Message::PickImage,
137160 })
138161 }
139162
@@ -261,6 +284,8 @@ enum Message {
261284 Hover(i32),
262285 Unhover(i32),
263286 Scrolled(i32, String, Viewport),
287+ PickImage,
288+ Picked(Option<PathBuf>),
264289 }
265290
266291 impl App {
@@ -368,6 +393,11 @@ impl cosmic::Application for App {
368393 Subscription::run(wakes)
369394 }
370395
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+
371401 fn update(&mut self, message: Message) -> Task<Message> {
372402 match message {
373403 Message::Tree => return self.take_tree(),
@@ -385,6 +415,23 @@ impl cosmic::Application for App {
385415 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
386416 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
387417 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),
388435 }
389436 Task::none()
390437 }
@@ -416,11 +463,15 @@ fn width_request(n: &Node) -> Option<f32> {
416463 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
417464 }
418465
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 {
420470 match n.str("align") {
471+ "start" => Alignment::Start,
421472 "center" => Alignment::Center,
422473 "end" => Alignment::End,
423- _ => Alignment::Start,
474+ _ => default,
424475 }
425476 }
426477
@@ -487,7 +538,9 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
487538 };
488539 let enabled = enabled && n.bool("sensitive") != Some(false);
489540 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;
491544 let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row));
492545
493546 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
506559 let mut row = Row::with_children(children(true))
507560 .spacing(spacing)
508561 .padding(margins(n))
509- .align_y(alignment(n));
562+ .align_y(alignment(n, Alignment::Center));
510563 // A row fills the width it is in only when it or something in
511564 // it asks to; otherwise a line of buttons would spread out.
512565 match width_request(n) {
@@ -522,7 +575,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
522575 let mut column = Column::with_children(children(false))
523576 .spacing(spacing)
524577 .padding(margins(n))
525- .align_x(alignment(n));
578+ .align_x(alignment(n, Alignment::Start));
526579 match width_request(n) {
527580 Some(w) => column = column.width(w),
528581 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
579632 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
580633 .into()
581634 }
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(),
587652 "button" => {
588653 let button = match n.str("kind") {
589654 "primary" => widget::button::suggested(n.label()),
@@ -629,6 +694,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
629694 };
630695 widget::text(glyph.to_owned())
631696 .size(n.num("size").unwrap_or(16.0) as f32)
697+ .font(EMOJI_FONT)
632698 .into()
633699 }
634700 // 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
669735 let mut content = Row::new()
670736 .spacing(4)
671737 .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));
673739 let count = n.num("count").unwrap_or(0.0);
674740 if count > 0.0 {
675741 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
817883 log::error!("jolt-cosmic: a window already ran in this process");
818884 return 2;
819885 }
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);
820889 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
821890 let mut settings = cosmic::app::Settings::default().size(size);
822891 match mode {
@@ -897,6 +966,64 @@ pub extern "C" fn cosmic_wake() {
897966 })
898967 }
899968
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+
9001027 // --- the C ABI: events -----------------------------------------------------------
9011028
9021029 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 in563 // 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: most700 // 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 @@
272272 ([node] (println (dump-str node)) nil))
273273
274274 ;; --- 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+
275294 (def backend
276295 {:name :cosmic
277296 :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 backend294 (def backend
276 {:name :cosmic295 {:name :cosmic
277 :create! create!296 :create! create!
modified glimmer-backends/glimmer-cosmic/src/glimmer_cosmic/ffi.clj +8 -0
@@ -54,6 +54,12 @@
5454 (ffi/defcfn event-text "cosmic_tree_event_text" [] :string)
5555 (ffi/defcfn event-num "cosmic_tree_event_num" [] :double)
5656
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+
5763 ;; --- the int/bool seam -------------------------------------------------------
5864 (def system-mode 0)
5965 (def dark-mode 1)
@@ -82,3 +88,5 @@
8288 (not (zero? (raw-node-insert-after parent child sibling))))
8389 (defn node-replace! [parent old new] (not (zero? (raw-node-replace parent old new))))
8490 (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))))