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>
228672d parent: 4706c92 modified
crates/jolt-cosmic/src/lib.rs +136 -2 | @@ -542,8 +542,102 @@ impl cosmic::Application for App { | ||
| 542 | 542 | |
| 543 | 543 | fn view(&self) -> Element<'_, Message> { |
| 544 | 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 | 643 | // --- props into layout ----------------------------------------------------------- |
| @@ -803,9 +897,19 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 803 | 897 | } |
| 804 | 898 | // A round picture, or the initial on a colour from the name: most |
| 805 | 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 | 910 | "avatar" => { |
| 807 | 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 | 913 | Some(handle) => widget::image(handle) |
| 810 | 914 | .width(size) |
| 811 | 915 | .height(size) |
| @@ -825,6 +929,28 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 825 | 929 | .class(filled(name_colour(n.label()), size / 2.0)) |
| 826 | 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 | 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 | 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 | 1080 | // Kept rather than refused, as in libvidya: a tag this backend has not |
| 947 | 1081 | // grown yet still shows its children. |
| 948 | 1082 | _ => 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: most | 898 | // 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 not | 1080 | // 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)) |