nandi/jolt-nativepublic Fork 0
228672d
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.

Answer the pointer on a face, and carry a dialog in the tree

The avatar arm painted a picture and nothing else: a client that asked a
face to answer a click, to report the pointer arriving, or to carry a card
under it was handed a portrait that did none of them. frq had asked for all
three since the day it drew its first face, and none of them had ever
happened — the profile behind every avatar in the window was unreachable.
It is a mouse_area and a tooltip now, which is what the reaction pill beside
it has always been.

And a `dialog` node, which is not painted where it stands. libcosmic puts a
dialog up itself, so the tree carries one wherever the client found it
convenient to write it and `App::dialog` goes and finds it there. `label` is
its heading, `body` the line under it, children its controls — except that a
child carrying `slot` "primary" or "secondary" becomes that action.

A dialog may also say `modal false`, and then `view` puts it up rather than
`dialog`. The hook cannot help being modal — `cosmic::app` wraps whatever it
returns in `popover(..).modal(true)` — and a modal popover hands the window
underneath it a cursor that is `Unavailable`. So a dialog opened by a hover
never hears the pointer leave, and what the pointer opened can never close
itself. Ours is the same widget in the same place with that one bargain off.

The popover is in the tree whether or not anything is in it, which
`cosmic::app` says of its own in one line and which this learned the long
way: iced keeps a widget's state by where it sits, so a wrapper that comes
and goes rebuilds everything under it — scroll positions included. Wrapping
only when a dialog appeared meant resting the pointer on a face jumped the
conversation behind it.

The popup reports its own pointer, too. Without that the client is told the
pointer left what opened the dialog and never told it arrived at the dialog,
so the only way to keep one up is not to move, and everything in it is out
of reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T20:22:32-07:00 Browse files
228672d parent: 4706c92
modified crates/jolt-cosmic/src/lib.rs +136 -2
@@ -542,8 +542,102 @@ impl cosmic::Application for App {
542542
543543 fn view(&self) -> Element<'_, Message> {
544544 let tree = &*self.tree;
545- element(tree, tree.root_id(), true, false)
545+ let root = element(tree, tree.root_id(), true, false);
546+ // A dialog that asked not to be modal, put up here rather than handed
547+ // to `dialog` below. It is the same widget in the same place — a
548+ // `popover` centres it exactly as `cosmic::app` does — and the whole
549+ // of the difference is that this one is not told to intercept the
550+ // pointer. That matters to anything the pointer opened: a modal
551+ // popover hands the window underneath it a cursor that is
552+ // `Unavailable`, so a face that opened a dialog on hover never hears
553+ // the pointer leave, and what it opened can never close itself.
554+ //
555+ // The popover is here whether or not there is anything in it, which
556+ // `cosmic::app` says of its own in one line and which this learned
557+ // the long way: iced keeps a widget's state by where it sits in the
558+ // tree, so a wrapper that comes and goes rebuilds everything under
559+ // it — and what "everything" holds is the scroll positions. Wrapping
560+ // only when a dialog appeared meant resting the pointer on a face
561+ // jumped the conversation behind it.
562+ let mut popover = widget::popover(root);
563+ if let Some(id) = find_dialog(tree, false) {
564+ // The dialog reports its own pointer, on the same two events a
565+ // face or a pill reports theirs. Without it a dialog the pointer
566+ // opened can only be read at arm's length: the client is told the
567+ // pointer left what opened it and never told it arrived here, so
568+ // the one way to keep it up is not to move — and everything in it
569+ // is out of reach.
570+ let popup = widget::mouse_area(dialog_of(tree, id))
571+ .on_enter(Message::Hover(id))
572+ .on_exit(Message::Unhover(id));
573+ popover = popover.popup(popup);
574+ }
575+ popover.into()
546576 }
577+
578+ /// The MODAL dialog the tree is carrying, if it is carrying one.
579+ ///
580+ /// A client says there is one by putting a `dialog` node in the tree and
581+ /// says there is not by leaving it out — the same way it says anything
582+ /// else. What comes back is libcosmic's own dialog: centred, over a
583+ /// dimmed window, and closed by the buttons the client hung on it.
584+ ///
585+ /// A dialog that says `modal false` does not come back here. This hook is
586+ /// the modal one whether the client wants it or not — `cosmic::app` wraps
587+ /// whatever it returns in `popover(..).modal(true)` — and `view` puts
588+ /// that kind up itself. See `dialog_of`.
589+ fn dialog(&self) -> Option<Element<'_, Message>> {
590+ let tree = &*self.tree;
591+ let id = find_dialog(tree, true)?;
592+ Some(dialog_of(tree, id))
593+ }
594+}
595+
596+/// The first `dialog` node in the tree whose modality is `modal`.
597+///
598+/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
599+/// not, which is the shape everything else here takes — a prop left out is
600+/// the ordinary answer.
601+fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
602+ let mut found = None;
603+ walk(t, t.root_id(), &mut |id, n| {
604+ if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
605+ found = Some(id);
606+ }
607+ });
608+ found
609+}
610+
611+/// One `dialog` node as libcosmic's dialog.
612+///
613+/// `label` is its heading and `body` the line under it. Children are its
614+/// controls, in order, except that a child carrying `slot` "primary" or
615+/// "secondary" becomes that action instead — which is where libcosmic puts
616+/// the buttons, at the foot and to the right.
617+fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
618+ let Some(n) = t.get(id) else {
619+ return widget::Space::new().width(0).height(0).into();
620+ };
621+ let mut d = widget::dialog();
622+ if !n.label().is_empty() {
623+ d = d.title(n.label().to_owned());
624+ }
625+ if !n.str("body").is_empty() {
626+ d = d.body(n.str("body").to_owned());
627+ }
628+ if let Some(w) = n.num("max-width") {
629+ d = d.max_width(w as f32);
630+ }
631+ for child in &n.children {
632+ let Some(c) = t.get(*child) else { continue };
633+ let el = element(t, *child, true, false);
634+ d = match c.str("slot") {
635+ "primary" => d.primary_action(el),
636+ "secondary" => d.secondary_action(el),
637+ _ => d.control(el),
638+ };
639+ }
640+ d.into()
547641 }
548642
549643 // --- props into layout -----------------------------------------------------------
@@ -803,9 +897,19 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
803897 }
804898 // A round picture, or the initial on a colour from the name: most
805899 // people in most rooms have no picture, so the initial IS the avatar.
900+ //
901+ // And the three things a face is for besides being looked at. It
902+ // painted as a picture and nothing else until now: a client that
903+ // asked a face to answer a click, to report the pointer arriving, or
904+ // to carry a card under it was handed a portrait that did none of
905+ // them — so the profile behind every avatar in the window was
906+ // unreachable, and the hover card written for it never appeared.
907+ // Those are the same three things `reaction` below does, so they are
908+ // done the same way: `mouse_area` for the press and the two edges of
909+ // the hover, and a `tooltip` for whatever was hung underneath.
806910 "avatar" => {
807911 let size = n.num("size").unwrap_or(32.0) as f32;
808- match picture(n.str("src")) {
912+ let face: Element<'_, Message> = match picture(n.str("src")) {
809913 Some(handle) => widget::image(handle)
810914 .width(size)
811915 .height(size)
@@ -825,6 +929,28 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
825929 .class(filled(name_colour(n.label()), size / 2.0))
826930 .into()
827931 }
932+ };
933+ // The hover is reported whether or not the face is enabled: it
934+ // says where the pointer is, which is true of an insensitive
935+ // picture too. The press is not — an insensitive subtree is out
936+ // of interaction, which is what `enabled` means here.
937+ let mut area = widget::mouse_area(face)
938+ .on_enter(Message::Hover(id))
939+ .on_exit(Message::Unhover(id));
940+ if enabled {
941+ area = area
942+ .on_press(Message::Click(id))
943+ .interaction(cosmic::iced::mouse::Interaction::Pointer);
944+ }
945+ if n.children.is_empty() {
946+ area.into()
947+ } else {
948+ widget::tooltip(
949+ area,
950+ Column::with_children(children(false)).spacing(4),
951+ widget::tooltip::Position::Bottom,
952+ )
953+ .into()
828954 }
829955 }
830956 // A pill: an emoji, how many people, and whether you are one of them.
@@ -943,6 +1069,14 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
9431069 .into()
9441070 }
9451071 }
1072+ // The one node that is not painted where it stands. libcosmic puts a
1073+ // dialog up itself, centred over the window and dimming what is
1074+ // behind it — `Application::dialog` is the hook, and it is asked for
1075+ // one separately from `view`. So the tree carries the dialog wherever
1076+ // the client found it convenient to write it, `App::dialog` goes and
1077+ // finds it there, and this leaves nothing behind in the layout. A
1078+ // node rendered in both places would be painted twice.
1079+ "dialog" => widget::Space::new().width(0).height(0).into(),
9461080 // Kept rather than refused, as in libvidya: a tag this backend has not
9471081 // grown yet still shows its children.
9481082 _ => Column::with_children(children(false))
@@ -542,8 +542,102 @@ impl cosmic::Application for App {
542 542
543 fn view(&self) -> Element<'_, Message> {543 fn view(&self) -> Element<'_, Message> {
544 let tree = &*self.tree;544 let tree = &*self.tree;
545- element(tree, tree.root_id(), true, false)545+ let root = element(tree, tree.root_id(), true, false);
546+ // A dialog that asked not to be modal, put up here rather than handed
547+ // to `dialog` below. It is the same widget in the same place — a
548+ // `popover` centres it exactly as `cosmic::app` does — and the whole
549+ // of the difference is that this one is not told to intercept the
550+ // pointer. That matters to anything the pointer opened: a modal
551+ // popover hands the window underneath it a cursor that is
552+ // `Unavailable`, so a face that opened a dialog on hover never hears
553+ // the pointer leave, and what it opened can never close itself.
554+ //
555+ // The popover is here whether or not there is anything in it, which
556+ // `cosmic::app` says of its own in one line and which this learned
557+ // the long way: iced keeps a widget's state by where it sits in the
558+ // tree, so a wrapper that comes and goes rebuilds everything under
559+ // it — and what "everything" holds is the scroll positions. Wrapping
560+ // only when a dialog appeared meant resting the pointer on a face
561+ // jumped the conversation behind it.
562+ let mut popover = widget::popover(root);
563+ if let Some(id) = find_dialog(tree, false) {
564+ // The dialog reports its own pointer, on the same two events a
565+ // face or a pill reports theirs. Without it a dialog the pointer
566+ // opened can only be read at arm's length: the client is told the
567+ // pointer left what opened it and never told it arrived here, so
568+ // the one way to keep it up is not to move — and everything in it
569+ // is out of reach.
570+ let popup = widget::mouse_area(dialog_of(tree, id))
571+ .on_enter(Message::Hover(id))
572+ .on_exit(Message::Unhover(id));
573+ popover = popover.popup(popup);
574+ }
575+ popover.into()
546 }576 }
577+
578+ /// The MODAL dialog the tree is carrying, if it is carrying one.
579+ ///
580+ /// A client says there is one by putting a `dialog` node in the tree and
581+ /// says there is not by leaving it out — the same way it says anything
582+ /// else. What comes back is libcosmic's own dialog: centred, over a
583+ /// dimmed window, and closed by the buttons the client hung on it.
584+ ///
585+ /// A dialog that says `modal false` does not come back here. This hook is
586+ /// the modal one whether the client wants it or not — `cosmic::app` wraps
587+ /// whatever it returns in `popover(..).modal(true)` — and `view` puts
588+ /// that kind up itself. See `dialog_of`.
589+ fn dialog(&self) -> Option<Element<'_, Message>> {
590+ let tree = &*self.tree;
591+ let id = find_dialog(tree, true)?;
592+ Some(dialog_of(tree, id))
593+ }
594+}
595+
596+/// The first `dialog` node in the tree whose modality is `modal`.
597+///
598+/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
599+/// not, which is the shape everything else here takes — a prop left out is
600+/// the ordinary answer.
601+fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
602+ let mut found = None;
603+ walk(t, t.root_id(), &mut |id, n| {
604+ if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
605+ found = Some(id);
606+ }
607+ });
608+ found
609+}
610+
611+/// One `dialog` node as libcosmic's dialog.
612+///
613+/// `label` is its heading and `body` the line under it. Children are its
614+/// controls, in order, except that a child carrying `slot` "primary" or
615+/// "secondary" becomes that action instead — which is where libcosmic puts
616+/// the buttons, at the foot and to the right.
617+fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
618+ let Some(n) = t.get(id) else {
619+ return widget::Space::new().width(0).height(0).into();
620+ };
621+ let mut d = widget::dialog();
622+ if !n.label().is_empty() {
623+ d = d.title(n.label().to_owned());
624+ }
625+ if !n.str("body").is_empty() {
626+ d = d.body(n.str("body").to_owned());
627+ }
628+ if let Some(w) = n.num("max-width") {
629+ d = d.max_width(w as f32);
630+ }
631+ for child in &n.children {
632+ let Some(c) = t.get(*child) else { continue };
633+ let el = element(t, *child, true, false);
634+ d = match c.str("slot") {
635+ "primary" => d.primary_action(el),
636+ "secondary" => d.secondary_action(el),
637+ _ => d.control(el),
638+ };
639+ }
640+ d.into()
547 }641 }
548 642
549 // --- props into layout -----------------------------------------------------------643 // --- props into layout -----------------------------------------------------------
@@ -803,9 +897,19 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
803 }897 }
804 // A round picture, or the initial on a colour from the name: most898 // A round picture, or the initial on a colour from the name: most
805 // people in most rooms have no picture, so the initial IS the avatar.899 // people in most rooms have no picture, so the initial IS the avatar.
900+ //
901+ // And the three things a face is for besides being looked at. It
902+ // painted as a picture and nothing else until now: a client that
903+ // asked a face to answer a click, to report the pointer arriving, or
904+ // to carry a card under it was handed a portrait that did none of
905+ // them — so the profile behind every avatar in the window was
906+ // unreachable, and the hover card written for it never appeared.
907+ // Those are the same three things `reaction` below does, so they are
908+ // done the same way: `mouse_area` for the press and the two edges of
909+ // the hover, and a `tooltip` for whatever was hung underneath.
806 "avatar" => {910 "avatar" => {
807 let size = n.num("size").unwrap_or(32.0) as f32;911 let size = n.num("size").unwrap_or(32.0) as f32;
808- match picture(n.str("src")) {912+ let face: Element<'_, Message> = match picture(n.str("src")) {
809 Some(handle) => widget::image(handle)913 Some(handle) => widget::image(handle)
810 .width(size)914 .width(size)
811 .height(size)915 .height(size)
@@ -825,6 +929,28 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
825 .class(filled(name_colour(n.label()), size / 2.0))929 .class(filled(name_colour(n.label()), size / 2.0))
826 .into()930 .into()
827 }931 }
932+ };
933+ // The hover is reported whether or not the face is enabled: it
934+ // says where the pointer is, which is true of an insensitive
935+ // picture too. The press is not — an insensitive subtree is out
936+ // of interaction, which is what `enabled` means here.
937+ let mut area = widget::mouse_area(face)
938+ .on_enter(Message::Hover(id))
939+ .on_exit(Message::Unhover(id));
940+ if enabled {
941+ area = area
942+ .on_press(Message::Click(id))
943+ .interaction(cosmic::iced::mouse::Interaction::Pointer);
944+ }
945+ if n.children.is_empty() {
946+ area.into()
947+ } else {
948+ widget::tooltip(
949+ area,
950+ Column::with_children(children(false)).spacing(4),
951+ widget::tooltip::Position::Bottom,
952+ )
953+ .into()
828 }954 }
829 }955 }
830 // A pill: an emoji, how many people, and whether you are one of them.956 // A pill: an emoji, how many people, and whether you are one of them.
@@ -943,6 +1069,14 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
943 .into()1069 .into()
944 }1070 }
945 }1071 }
1072+ // The one node that is not painted where it stands. libcosmic puts a
1073+ // dialog up itself, centred over the window and dimming what is
1074+ // behind it — `Application::dialog` is the hook, and it is asked for
1075+ // one separately from `view`. So the tree carries the dialog wherever
1076+ // the client found it convenient to write it, `App::dialog` goes and
1077+ // finds it there, and this leaves nothing behind in the layout. A
1078+ // node rendered in both places would be painted twice.
1079+ "dialog" => widget::Space::new().width(0).height(0).into(),
946 // Kept rather than refused, as in libvidya: a tag this backend has not1080 // Kept rather than refused, as in libvidya: a tag this backend has not
947 // grown yet still shows its children.1081 // grown yet still shows its children.
948 _ => Column::with_children(children(false))1082 _ => Column::with_children(children(false))