Browse / github.com/storytold/artcraft / patch
Fix native model picker flyout closing when the pointer leaves the family row
Fix native model picker flyout closing when the pointer leaves the family row **Problem:** In `artcraft-native`, clicking a family in the model picker opens a flyout of its variants, but the flyout disappears as soon as the pointer leaves the family row, so variants can't be selected. **Cause:** the hovered family was stored in egui temp data as `Option<String>` but read back as `String`, so it never persisted and the flyout only showed while the row itself was hovered. **Changes** - Read the hovered family back with the type it was stored as. - Add a small hover bridge around the flyout so crossing the gap between the row and the flyout doesn't dismiss it. - The main
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- mikekuriger (imported from pull request #2122, open)
- Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373- Size
- +61 −16 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
fab4f32728db8c399758e0a4
How it went
No other operator has reported using it yet.
Diff
raw · artcraft-native/src/prompt_box/model_selector.rs artcraft-native/src/prompt_box/pickers.rs
@@ -5,7 +5,7 @@ use std::collections::HashMap; -use egui::{Align2, Id, Order, Popup, Rect, Stroke, Ui, pos2, vec2};+use egui::{Align2, Id, Order, Popup, PopupCloseBehavior, Rect, Stroke, Ui, pos2, vec2}; use crate::backend::providers::Provider; use crate::models::{self, ModelInfo};@@ -19,6 +19,9 @@ const FLYOUT_WIDTH: f32 = 300.0; const PROVIDER_FLYOUT_WIDTH: f32 = 220.0; /// Hovering a model this long opens its providers (`providerTooltipDelayMs`). const PROVIDER_DELAY_SECS: f64 = 0.3;+/// How far past a flyout's edge the pointer still counts as being on it, so crossing the gap+/// between a family's row and its flyout doesn't dismiss the flyout.+const FLYOUT_BRIDGE: f32 = 14.0; /// What was picked: a model, and the provider when one was chosen with it. pub type Pick = (String, Option<Provider>);@@ -41,13 +44,19 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio let rows = Rows { id, selected, show_badges, providers }; let mut picked = None; let hovered_family_id = id.with("family");- pickers::popover(&resp, popup_id, |ui| {+ // Flyouts are separate areas, so a click inside one would count as "outside" the popover and+ // close it before the pick lands. The popover ignores clicks; outside clicks are handled below.+ let flyouts_id = id.with("flyout-rects");+ let panel = pickers::popover_with(&resp, popup_id, PopupCloseBehavior::IgnoreClicks, |ui| {+ ui.data_mut(|d| d.insert_temp(flyouts_id, Vec::<Rect>::new())); ui.set_width(PANEL_WIDTH); widgets::menu_header(ui, "Select Model"); let max_h = ui.ctx().content_rect().height() * 0.6; egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| match models::grouped(models) { Some(families) => {- let mut hovered: Option<String> = ui.data(|d| d.get_temp(hovered_family_id));+ let mut hovered: Option<String> = ui.data(|d| d.get_temp(hovered_family_id)).flatten();+ let pointer = ui.input(|i| i.pointer.hover_pos());+ let in_flyout_zone = hovered.as_ref().and_then(|f| ui.data(|d| d.get_temp::<Rect>(flyout_rect_id(id, f)))).is_some_and(|r| pointer.is_some_and(|p| r.expand(FLYOUT_BRIDGE).contains(p))); for family in &families { let contains_selected = selected.is_some_and(|s| family.models.iter().any(|m| m.id == s.id)); let first = family.models[0];@@ -56,7 +65,7 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio let is_open = hovered.as_deref() == Some(family.name.as_str()); if family.models.len() == 1 { let (row_hovered, pick) = rows.row(ui, first);- if row_hovered {+ if row_hovered && !in_flyout_zone { hovered = None; } if pick.is_some() {@@ -65,7 +74,7 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio continue; } let row = widgets::menu_row(ui, RowLead::Logo(&tex), &family.name, subtitle, false, true, is_open);- if row.hovered() || row.clicked() {+ if row.hovered() || row.clicked() || pointer.is_some_and(|p| row.rect.contains(p)) { hovered = Some(family.name.clone()); } if hovered.as_deref() == Some(family.name.as_str()) {@@ -85,6 +94,14 @@ pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Optio }, }); });+ if let Some(panel) = panel {+ let click = ui.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten());+ let flyouts: Vec<Rect> = ui.data(|d| d.get_temp(flyouts_id)).unwrap_or_default();+ let outside = |pos| !panel.contains(pos) && !resp.rect.contains(pos) && !flyouts.iter().any(|r| r.contains(pos));+ if click.is_some_and(outside) {+ Popup::close_id(ui.ctx(), popup_id);+ }+ } if picked.is_some() { Popup::close_id(ui.ctx(), popup_id); ui.data_mut(|d| {@@ -107,7 +124,7 @@ impl Rows<'_> { /// A family's variants, to the right of its row. fn family_flyout(&self, ui: &Ui, row: Rect, family: &str, list: &[&ModelInfo]) -> Option<Pick> { let mut picked = None;- egui::Area::new(self.id.with(("flyout", family))).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| {+ let area = egui::Area::new(self.id.with(("flyout", family))).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| { widgets::popover_frame().show(ui, |ui| { ui.set_width(FLYOUT_WIDTH); widgets::menu_header(ui, family);@@ -118,6 +135,8 @@ impl Rows<'_> { } }); });+ remember_flyout(ui.ctx(), self.id, area.response.rect);+ ui.ctx().data_mut(|d| d.insert_temp(flyout_rect_id(self.id, family), area.response.rect)); picked } @@ -157,7 +176,9 @@ impl Rows<'_> { } let mut picked = row.clicked().then(|| (m.id.clone(), None)); if open {- if let Some(provider) = provider_flyout(ui, self.id.with(("providers", &m.id)), row.rect, allowed, current) {+ let (provider, rect) = provider_flyout(ui, self.id.with(("providers", &m.id)), row.rect, allowed, current);+ remember_flyout(ui.ctx(), self.id, rect);+ if let Some(provider) = provider { picked = Some((m.id.clone(), Some(provider))); } }@@ -165,6 +186,16 @@ impl Rows<'_> { } } +/// Where a family's flyout was last drawn.+fn flyout_rect_id(selector: Id, family: &str) -> Id {+ selector.with(("flyout-rect", family))+}++/// Records a flyout's rect so clicks inside it don't close the selector.+fn remember_flyout(ctx: &egui::Context, selector: Id, rect: Rect) {+ ctx.data_mut(|d| d.get_temp_mut_or_default::<Vec<Rect>>(selector.with("flyout-rects")).push(rect));+}+ /// The provider's logo on a chip at the row's right; on the brand blue for the selected model. fn provider_chip(ui: &Ui, row: Rect, provider: Provider, selected: bool) { let chip = Rect::from_center_size(pos2(row.right() - 22.0, row.center().y), vec2(26.0, 20.0));@@ -175,9 +206,9 @@ fn provider_chip(ui: &Ui, row: Rect, provider: Provider, selected: bool) { } /// "Select Provider" (`ProviderTooltipContent`), to the right of a model's row.-fn provider_flyout(ui: &Ui, id: Id, row: Rect, allowed: &[Provider], current: Provider) -> Option<Provider> {+fn provider_flyout(ui: &Ui, id: Id, row: Rect, allowed: &[Provider], current: Provider) -> (Option<Provider>, Rect) { let mut picked = None;- egui::Area::new(id).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| {+ let area = egui::Area::new(id).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| { widgets::popover_frame().show(ui, |ui| { ui.set_width(PROVIDER_FLYOUT_WIDTH); widgets::menu_header(ui, "Select Provider");@@ -194,7 +225,7 @@ fn provider_flyout(ui: &Ui, id: Id, row: Rect, allowed: &[Provider], current: Pr } }); });- picked+ (picked, area.response.rect) } /// The video picker's capability badges ("Audio Support", "Start/End", "Reference").@@ -222,6 +253,7 @@ mod tests { struct State { models: Vec<ModelInfo>,+ selected: usize, picks: HashMap<String, Provider>, picked: Vec<Pick>, fonts_ready: bool,@@ -258,8 +290,13 @@ mod tests { } fn build() -> Harness<'static, State> {- let models = ["seedream_4", "nano_banana_pro"].iter().map(|id| ModelInfo { id: (*id).into(), name: models::display_name(id), creator: models::creator_for(id).into(), ..Default::default() }).collect();- let state = State { models, picks: HashMap::new(), picked: Vec::new(), fonts_ready: false };+ build_with(&["seedream_4", "nano_banana_pro"])+ }++ fn build_with(ids: &[&str]) -> Harness<'static, State> {+ let selected = 0;+ let models: Vec<ModelInfo> = ids.iter().map(|id| ModelInfo { id: (*id).into(), name: models::display_name(id), creator: models::creator_for(id).into(), ..Default::default() }).collect();+ let state = State { models, selected, picks: HashMap::new(), picked: Vec::new(), fonts_ready: false }; let mut harness = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, s: &mut State| { if !s.fonts_ready {@@ -268,7 +305,7 @@ mod tests { return; } ui.add_space(500.0);- if let Some(pick) = model_selector(ui, Id::new("test-model"), &s.models, s.models.first(), false, Some(&s.picks)) {+ if let Some(pick) = model_selector(ui, Id::new("test-model"), &s.models, s.models.get(s.selected), false, Some(&s.picks)) { s.picked.push(pick); } },@@ -2,7 +2,7 @@ //! option list, the duration slider, and on/off toggles. use egui::{Popup, PopupCloseBehavior};-use egui::{Id, RectAlign, Response, Ui};+use egui::{Id, Rect, RectAlign, Response, Ui}; use crate::theme; use crate::ui::icons::Icon;@@ -84,10 +84,18 @@ pub fn option_picker<T: Clone + PartialEq>(ui: &mut Ui, id: Id, trigger: Trigger /// Opens a popover above `anchor` (toggled by clicking it) and fills it with `content`. pub fn popover(anchor: &Response, popup_id: Id, content: impl FnOnce(&mut Ui)) {- Popup::from_toggle_button_response(anchor).id(popup_id).align(RectAlign::TOP_START).align_alternatives(&[RectAlign::BOTTOM_START, RectAlign::TOP_END]).gap(6.0).frame(widgets::popover_frame()).close_behavior(PopupCloseBehavior::CloseOnClickOutside).show(|ui| {+ popover_with(anchor, popup_id, PopupCloseBehavior::CloseOnClickOutside, content);+}++/// Like `popover`, with the click-to-close behavior chosen by the caller; returns the popover's+/// screen rect while it's open. Callers with flyouts (separate areas) pass `IgnoreClicks` and+/// close the popover themselves, since a click inside a flyout counts as outside the popover.+pub fn popover_with(anchor: &Response, popup_id: Id, close_behavior: PopupCloseBehavior, content: impl FnOnce(&mut Ui)) -> Option<Rect> {+ let popup = Popup::from_toggle_button_response(anchor).id(popup_id).align(RectAlign::TOP_START).align_alternatives(&[RectAlign::BOTTOM_START, RectAlign::TOP_END]).gap(6.0).frame(widgets::popover_frame()).close_behavior(close_behavior);+ popup.show(|ui| { ui.spacing_mut().item_spacing.y = 2.0; content(ui);- });+ }).map(|inner| inner.response.rect) } /// A toolbar pill that opens a popover of custom `content` above it.