From a475fb044531c50ff6d505f4008ee3add901f51b Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:20:05 +0000 Subject: [PATCH 01/13] Add a grouped virtual list with sticky headers. Virtualise group headers and rows, pin the active header, and expand or collapse groups from the pointer and the keyboard, with an AccessKit tree. Graft-Changeset: c5c614ab6527af7136f32fffb1c9d8fa3f44b8712bb18b4de989ed59f3d1a89da Graft-Session: ses_01M649V5QGWTK5N1BVPG1YTQ06 Graft-Work-Item: gh:96 Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Graft-Batch: 20 --- README.md | 1 + changelog.d/gh-96-grouped-list.md | 3 + crates/fluent2-egui/src/data/grouped_list.rs | 693 +++++++++++++++++++ crates/fluent2-egui/src/data/mod.rs | 8 + crates/fluent2-egui/tests/grouped_list.rs | 191 +++++ crates/gallery/src/pages/grouped_list.rs | 50 ++ crates/gallery/src/pages/mod.rs | 3 + 7 files changed, 949 insertions(+) create mode 100644 changelog.d/gh-96-grouped-list.md create mode 100644 crates/fluent2-egui/src/data/grouped_list.rs create mode 100644 crates/fluent2-egui/tests/grouped_list.rs create mode 100644 crates/gallery/src/pages/grouped_list.rs diff --git a/README.md b/README.md index 5104675..25155ff 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ What the crate offers, by module, mapped to Fluent 2. Rows are sorted by type na | `find::FindBar` (`FindState`, `FindProvider`, `FindItems`, `TextLines`) | egui-only (in-app find, Ctrl+F) | | Find | | `editable_list::EditableList` | egui-only (inline list editing) | | Editable list | | `html::HtmlView` | egui-only (sanitised HTML, for mail) | `html` | HTML | +| `grouped_list::GroupedList` (`Group`, `GroupedListState`, `GroupedListResponse`) | egui-only (grouped virtual list, sticky headers) | | Grouped list | | `list::List` | List | | List rows | | `markdown::MarkdownView` | egui-only (Markdown) | `markdown` | Markdown | | `NavigationView` | Nav | | Navigation view | diff --git a/changelog.d/gh-96-grouped-list.md b/changelog.d/gh-96-grouped-list.md new file mode 100644 index 0000000..bd4fac6 --- /dev/null +++ b/changelog.d/gh-96-grouped-list.md @@ -0,0 +1,3 @@ +### Added + +- `GroupedList`: a virtualised list of expandable groups. Only the rows in view are built, the current group's header sticks to the top while its rows scroll, and Left, Right, Enter and a header click expand and collapse. Headers are AccessKit groups; rows are list-box options. diff --git a/crates/fluent2-egui/src/data/grouped_list.rs b/crates/fluent2-egui/src/data/grouped_list.rs new file mode 100644 index 0000000..787d561 --- /dev/null +++ b/crates/fluent2-egui/src/data/grouped_list.rs @@ -0,0 +1,693 @@ +//! Grouped virtual list: measured rows under sticky, expandable group headers. +//! +//! Only entries that intersect the viewport are laid out. While a group's rows occupy the +//! top of the viewport, its header is painted stuck there. Headers are AccessKit groups with +//! an expanded state; rows are list-box options (or list items when selection is off). +//! Up and Down move focus. Left collapses the focused group, Right expands it, and Enter or +//! Space toggles a focused header or activates a focused row. + +use egui::accesskit::Role; +use egui::{ + Align, CornerRadius, Event, EventFilter, FontId, Id, Key, Layout, Modifiers, Pos2, Rect, ScrollArea, + Sense, Stroke, Ui, UiBuilder, Vec2, WidgetInfo, +}; + +use super::RowId; +use super::data_grid::{CellCtx, Selection, SelectionMode}; +use crate::data::rows::{ + HeightCache, MAX_MEASURED_HEIGHT, find_row, row_offsets, select_only, toggle, visible_rows_variable, +}; +use crate::foundation::focus::paint_focus_ring; +use crate::theme::{Theme, c}; +use fluent2_tokens::generated::global::{border_radius, spacing_horizontal, spacing_vertical}; + +/// One group: a sticky header and the rows shown while it is expanded. +#[derive(Clone, Debug)] +pub struct Group { + /// Stable id. Expansion is remembered under this, not under the group's index. + pub id: RowId, + /// Header text, also the accessible name of the group. + pub label: String, + /// Rows the group holds. A collapsed group draws none of them. + pub row_count: usize, +} + +/// State of a grouped list. Keep it in the app and pass `&mut` every frame. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GroupedListState { + /// Selected rows. Headers are never selected. + pub selection: Selection, + /// Focused header or row. `None` before the list is focused. + pub focus: Option, + /// Last known index of `focus` in the flattened list. + pub focus_hint: usize, + /// Groups whose expansion differs from the default. With the default (start expanded) this + /// lists collapsed groups; with [`GroupedList::start_collapsed`] it lists expanded ones. + pub toggled: Vec, +} + +impl GroupedListState { + /// Whether `group` is expanded. + #[must_use] + pub fn is_expanded(&self, group: RowId, start_collapsed: bool) -> bool { + let listed = self.toggled.contains(&group); + if start_collapsed { listed } else { !listed } + } + + fn set_expanded(&mut self, group: RowId, expanded: bool, start_collapsed: bool) { + let list_it = expanded == start_collapsed; + let has = self.toggled.contains(&group); + if list_it && !has { + self.toggled.push(group); + } + if !list_it && has { + self.toggled.retain(|id| *id != group); + } + } +} + +/// What [`GroupedList::show`] reports. +#[derive(Clone, Debug)] +pub struct GroupedListResponse { + /// The scroll area. + pub response: egui::Response, + /// Selection changed this frame. + pub selection_changed: bool, + /// A group was expanded or collapsed this frame. + pub expansion_changed: bool, + /// Enter or Space on a focused row. + pub activated: Option, + /// Header stuck to the top of the viewport, if one is. + pub sticky: Option, + /// How many row closures ran this frame (headers are not counted). + pub rows_drawn: usize, +} + +/// One flattened entry. +struct Slot { + id: RowId, + group: RowId, + group_index: usize, + /// `None` for the header. + row: Option, + position: usize, + set_size: usize, + expanded: bool, +} + +/// A virtualised list of expandable groups. +/// +/// ``` +/// # use fluent2_egui::data::grouped_list::{Group, GroupedList, GroupedListState}; +/// # use fluent2_egui::data::RowId; +/// # fn demo(ui: &mut egui::Ui) { +/// let groups = [Group { id: RowId(1), label: "Today".into(), row_count: 4 }]; +/// let mut state = GroupedListState::default(); +/// let _ = GroupedList::new("inbox", &groups).show(ui, &mut state, |cell, ui| { +/// ui.label(format!("Message {}", cell.row)); +/// }); +/// # } +/// ``` +pub struct GroupedList<'a> { + id: Id, + groups: &'a [Group], + row_id: Option RowId + 'a>>, + selection_mode: SelectionMode, + row_height: Option, + header_height: Option, + estimate: f32, + label: Option, + start_collapsed: bool, +} + +fn hashed(value: &T) -> u64 { + use std::hash::Hasher; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() +} + +impl<'a> GroupedList<'a> { + /// Groups in order. `id_salt` keeps the widget id stable. + #[must_use] + pub fn new(id_salt: impl std::hash::Hash, groups: &'a [Group]) -> Self { + Self { + id: Id::unique(hashed(&id_salt)), + groups, + row_id: None, + selection_mode: SelectionMode::Single, + row_height: None, + header_height: None, + estimate: 32.0, + label: None, + start_collapsed: false, + } + } + + /// Id of the row at `index` in `group`. The default mixes the two so ids stay unique. + #[must_use] + pub fn row_id(mut self, f: impl Fn(RowId, usize) -> RowId + 'a) -> Self { + self.row_id = Some(Box::new(f)); + self + } + + /// How rows are selected. Headers are never selected. Default: [`SelectionMode::Single`]. + #[must_use] + pub fn selection_mode(mut self, mode: SelectionMode) -> Self { + self.selection_mode = mode; + self + } + + /// Minimum row height. Default: `Theme::data.list_row_height`. + #[must_use] + pub fn row_height(mut self, height: f32) -> Self { + self.row_height = Some(height); + self + } + + /// Header height. Default: `Theme::data.list_row_height`. + #[must_use] + pub fn header_height(mut self, height: f32) -> Self { + self.header_height = Some(height); + self + } + + /// Height used for a row that has not been measured yet. + #[must_use] + pub fn estimate(mut self, height: f32) -> Self { + self.estimate = height; + self + } + + /// Accessible name of the list. + #[must_use] + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Groups start collapsed. The default is expanded. + #[must_use] + pub fn start_collapsed(mut self) -> Self { + self.start_collapsed = true; + self + } + + /// Draw the list. `row` paints one visible row of an expanded group (`cell.row` is the + /// index inside that group). Collapsed groups and rows outside the viewport are skipped. + pub fn show( + self, + ui: &mut Ui, + state: &mut GroupedListState, + mut row: impl FnMut(CellCtx, &mut Ui), + ) -> GroupedListResponse { + let theme = Theme::get(ui.ctx()); + let row_min = finite_min(self.row_height, theme.data.list_row_height); + let header_h = finite_min(self.header_height, theme.data.list_row_height); + let estimate = if self.estimate.is_finite() { + self.estimate.clamp(row_min, MAX_MEASURED_HEIGHT) + } else { + row_min + }; + let fill_hover = c(theme.color.subtle_background_hover); + let fill_selected = c(theme.color.subtle_background_selected); + let header_fill = c(theme.color.subtle_background_hover); + let ink = c(theme.color.neutral_foreground_1); + let stroke = Stroke::new(1.0, c(theme.color.neutral_stroke_2)); + let ring = CornerRadius::same(border_radius::MEDIUM as u8); + let start_collapsed = self.start_collapsed; + let mode = self.selection_mode; + let id = self.id; + + let mut slots: Vec = Vec::new(); + for (group_index, group) in self.groups.iter().enumerate() { + let expanded = state.is_expanded(group.id, start_collapsed); + slots.push(Slot { + id: group.id, + group: group.id, + group_index, + row: None, + position: 0, + set_size: group.row_count, + expanded, + }); + if !expanded { + continue; + } + for index in 0..group.row_count { + let row_id = self.row_id.as_ref().map_or_else( + || RowId(group.id.0.wrapping_mul(1_000_003).wrapping_add(index as u64)), + |f| f(group.id, index), + ); + slots.push(Slot { + id: row_id, + group: group.id, + group_index, + row: Some(index), + position: index + 1, + set_size: group.row_count, + expanded: true, + }); + } + } + + let cache_id = id.with("grouped_heights"); + let mut cache = ui + .ctx() + .data_mut(|data| data.remove_temp::(cache_id)) + .unwrap_or_default(); + let offsets = row_offsets(slots.len(), &|index| match slots.get(index) { + Some(slot) if slot.row.is_none() => header_h, + Some(slot) => cache.get(slot.id, estimate).max(row_min), + None => estimate, + }); + + let groups = self.groups; + let label = self.label.clone(); + let mut selection_changed = false; + let mut expansion_changed = false; + let mut activated = None; + let mut sticky_id = None; + let mut rows_drawn = 0usize; + + let scroll = ScrollArea::vertical() + .id_salt(id) + .auto_shrink([false, false]) + .show_viewport(ui, |ui, viewport| { + let content_h = offsets.last().copied().unwrap_or(0.0); + let (content, _) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), content_h), Sense::hover()); + let origin = content.min; + let width = content.width(); + let clip = ui.clip_rect(); + let list_response = ui.interact(clip, id, Sense::click()); + let visible = visible_rows_variable(viewport, &offsets); + let page = visible.len().saturating_sub(1).max(1); + let focused = list_response.has_focus(); + let slot_id = |index: usize| slots.get(index).map(|slot| slot.id).unwrap_or(RowId(u64::MAX)); + + let mut moved = false; + if focused { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter( + id, + EventFilter { + tab: false, + horizontal_arrows: true, + vertical_arrows: true, + escape: false, + }, + ); + }); + for (key, modifiers) in keys(ui) { + match key { + Key::ArrowUp + | Key::ArrowDown + | Key::Home + | Key::End + | Key::PageUp + | Key::PageDown => { + step_focus(state, slots.len(), &slot_id, key, page); + moved = true; + if mode != SelectionMode::None + && !modifiers.command + && let Some(index) = focus_at(state, slots.len(), &slot_id) + && slots.get(index).is_some_and(|slot| slot.row.is_some()) + { + select_only(&mut state.selection, slot_id(index)); + selection_changed = true; + } + } + Key::ArrowLeft => { + expansion_changed |= + set_focused_group(state, &slots, &slot_id, false, start_collapsed); + } + Key::ArrowRight => { + expansion_changed |= + set_focused_group(state, &slots, &slot_id, true, start_collapsed); + } + Key::Enter | Key::Space => { + if let Some(index) = focus_at(state, slots.len(), &slot_id) + && let Some(slot) = slots.get(index) + { + if let Some(row_index) = slot.row { + activated = Some(CellCtx { + row: row_index, + row_id: slot.id, + column: "", + column_index: 0, + selected: state.selection.selected.contains(&slot.id), + focused: true, + }); + } else { + let open = state.is_expanded(slot.group, start_collapsed); + state.set_expanded(slot.group, !open, start_collapsed); + expansion_changed = true; + } + } + } + _ => {} + } + } + } + + if moved && let Some(index) = focus_at(state, slots.len(), &slot_id) { + let top = offsets.get(index).copied().unwrap_or(0.0); + let height = offsets.get(index + 1).copied().unwrap_or(top) - top; + ui.scroll_to_rect( + Rect::from_min_size(Pos2::new(origin.x, origin.y + top), Vec2::new(width, height)), + None, + ); + } + + let focus_index = focus_at(state, slots.len(), &slot_id); + let mut clicked = false; + let mut heights_changed = false; + let mut active = None; + + // Last header whose top is at or above the viewport, and whose successor has + // not yet reached the pin line, is the sticky one. + let sticky_index = (0..slots.len()) + .filter(|&index| slots.get(index).is_some_and(|slot| slot.row.is_none())) + .rfind(|&index| offsets.get(index).copied().unwrap_or(0.0) <= viewport.min.y + 0.5) + .filter(|&index| { + let next = ((index + 1)..slots.len()) + .find(|&i| slots.get(i).is_some_and(|slot| slot.row.is_none())); + next.is_none_or(|next| { + offsets.get(next).copied().unwrap_or(f32::MAX) > viewport.min.y + header_h * 0.5 + }) + }); + + for index in visible.clone() { + let Some(slot_group) = slots.get(index).map(|slot| slot.group) else { + continue; + }; + let top = origin.y + offsets.get(index).copied().unwrap_or(0.0); + let height = (offsets.get(index + 1).copied().unwrap_or(0.0) + - offsets.get(index).copied().unwrap_or(0.0)) + .max(1.0); + let rect = Rect::from_min_size(Pos2::new(origin.x, top), Vec2::new(width, height)); + let is_header = slots.get(index).is_some_and(|slot| slot.row.is_none()); + if is_header { + let text = groups + .get(slots[index].group_index) + .map(|group| group.label.as_str()) + .unwrap_or(""); + let expanded = slots[index].expanded; + paint_header(ui, rect, text, expanded, header_fill, ink, stroke); + let hit = ui.interact(rect, id.with(("hdr", slots[index].id.0)), Sense::click()); + hit.widget_info(|| WidgetInfo::labeled(Role::Unknown, ui.is_enabled(), text)); + let set_size = slots[index].set_size; + ui.ctx().accesskit_node_builder(hit.id, |node| { + node.set_role(Role::Group); + node.set_label(text); + node.set_expanded(expanded); + if set_size > 0 { + node.set_size_of_set(set_size); + } + }); + if hit.clicked() { + clicked = true; + state.focus = Some(slots[index].id); + state.focus_hint = index; + let open = state.is_expanded(slot_group, start_collapsed); + state.set_expanded(slot_group, !open, start_collapsed); + expansion_changed = true; + } + } else { + let slot_id_now = slots[index].id; + let row_index = slots[index].row.unwrap_or(0); + let selected = state.selection.selected.contains(&slot_id_now); + if selected { + ui.painter().rect_filled(rect, 0.0, fill_selected); + } else if ui.rect_contains_pointer(rect) { + ui.painter().rect_filled(rect, 0.0, fill_hover); + } + let hit = ui.interact(rect, id.with(("row", slot_id_now.0)), Sense::click()); + // A rect whose min is the row's screen position makes min_rect().height() + // include that offset, so the row would grow every frame. Measure in a + // rect that starts at y 0. + let inner = Rect::from_min_size( + Pos2::new(rect.min.x + spacing_horizontal::S, spacing_vertical::S_NUDGE), + Vec2::new( + (rect.width() - 2.0 * spacing_horizontal::S).max(1.0), + MAX_MEASURED_HEIGHT, + ), + ); + let mut child = ui.new_child( + UiBuilder::new() + .max_rect(inner) + .layout(Layout::top_down(Align::Min)) + .id_salt(("grow", slot_id_now.0)) + .accessibility_parent(hit.id), + ); + child.set_clip_rect(rect.intersect(clip)); + row( + CellCtx { + row: row_index, + row_id: slot_id_now, + column: "", + column_index: 0, + selected, + focused: focused && focus_index == Some(index), + }, + &mut child, + ); + rows_drawn += 1; + let measured = child.min_rect().height() + 2.0 * spacing_vertical::S_NUDGE; + heights_changed |= cache.set(slot_id_now, measured.max(row_min)); + let name = groups + .get(slots[index].group_index) + .map(|group| format!("{} {}", group.label, row_index + 1)) + .unwrap_or_default(); + hit.widget_info(|| { + WidgetInfo::labeled(Role::Unknown, ui.is_enabled(), name.as_str()) + }); + let position = slots[index].position; + let set_size = slots[index].set_size; + ui.ctx().accesskit_node_builder(hit.id, |node| { + if mode == SelectionMode::None { + node.set_role(Role::ListItem); + } else { + node.set_role(Role::ListBoxOption); + node.set_selected(selected); + } + node.set_label(name.as_str()); + node.set_position_in_set(position); + node.set_size_of_set(set_size); + }); + if focused && focus_index == Some(index) { + active = Some(hit.id.accesskit_id()); + } + if hit.clicked() { + clicked = true; + state.focus = Some(slot_id_now); + state.focus_hint = index; + if mode == SelectionMode::Multi && ui.input(|input| input.modifiers.command) { + toggle(&mut state.selection, slot_id_now); + } else if mode != SelectionMode::None { + select_only(&mut state.selection, slot_id_now); + } + selection_changed = true; + } + } + } + + if let Some(index) = sticky_index { + let real_top = origin.y + offsets.get(index).copied().unwrap_or(0.0); + if real_top < clip.min.y - 0.5 { + let stuck = + Rect::from_min_size(Pos2::new(origin.x, clip.min.y), Vec2::new(width, header_h)); + let text = groups + .get(slots[index].group_index) + .map(|group| group.label.as_str()) + .unwrap_or(""); + paint_header(ui, stuck, text, slots[index].expanded, header_fill, ink, stroke); + sticky_id = Some(slots[index].group); + } + } + + if focused && let Some(index) = focus_index { + let top = origin.y + offsets.get(index).copied().unwrap_or(0.0); + let height = (offsets.get(index + 1).copied().unwrap_or(0.0) + - offsets.get(index).copied().unwrap_or(0.0)) + .max(1.0); + paint_focus_ring( + ui, + Rect::from_min_size(Pos2::new(origin.x, top), Vec2::new(width, height)).shrink(2.0), + ring, + ); + } + if clicked { + list_response.request_focus(); + } + let enabled = ui.is_enabled(); + list_response.widget_info(|| match &label { + Some(text) => WidgetInfo::labeled(Role::Unknown, enabled, text.as_str()), + None => WidgetInfo::new(Role::Unknown), + }); + ui.ctx().accesskit_node_builder(list_response.id, |node| { + node.set_role(if mode == SelectionMode::None { + Role::List + } else { + Role::ListBox + }); + if let Some(text) = &label { + node.set_label(text.as_str()); + } + if let Some(row_node) = active { + node.set_active_descendant(row_node); + } + }); + (list_response, heights_changed) + }); + + let (response, heights_changed) = scroll.inner; + if heights_changed { + ui.ctx().request_repaint(); + } + ui.ctx().data_mut(|data| data.insert_temp(cache_id, cache)); + GroupedListResponse { + response, + selection_changed, + expansion_changed, + activated, + sticky: sticky_id, + rows_drawn, + } + } +} + +fn finite_min(requested: Option, fallback: f32) -> f32 { + requested + .filter(|height| height.is_finite() && *height >= 1.0) + .unwrap_or(fallback) + .max(1.0) +} + +fn paint_header( + ui: &Ui, + rect: Rect, + text: &str, + expanded: bool, + fill: egui::Color32, + ink: egui::Color32, + stroke: Stroke, +) { + ui.painter().rect_filled(rect, 0.0, fill); + ui.painter().hline(rect.x_range(), rect.bottom(), stroke); + // A stroked chevron, same shape as TreeView, so it does not need a glyph the + // proportional font may not have. + let slot = Rect::from_min_size( + Pos2::new(rect.min.x + spacing_horizontal::XS, rect.min.y), + Vec2::new(spacing_horizontal::L, rect.height()), + ); + paint_chevron(ui, slot, if expanded { 1.0 } else { 0.0 }, ink); + let galley = ui + .painter() + .layout_no_wrap(text.to_owned(), FontId::proportional(13.0), ink); + let pos = Pos2::new(slot.max.x, rect.center().y - galley.size().y * 0.5); + ui.painter().galley(pos, galley, ink); +} + +/// Pointing right when collapsed (`turn` 0) and down when expanded (`turn` 1). +fn paint_chevron(ui: &Ui, slot: Rect, turn: f32, color: egui::Color32) { + let arm_x = spacing_horizontal::S / 4.0; + let arm_y = spacing_horizontal::S / 2.0; + let rotation = egui::emath::Rot2::from_angle(turn.clamp(0.0, 1.0) * std::f32::consts::FRAC_PI_2); + let centre = slot.center().to_vec2(); + let points = [ + Vec2::new(-arm_x, -arm_y), + Vec2::new(arm_x, 0.0), + Vec2::new(-arm_x, arm_y), + ] + .map(|offset| (centre + rotation * offset).to_pos2()); + ui.painter().line(points.to_vec(), Stroke::new(1.0, color)); +} + +fn focus_at(state: &GroupedListState, len: usize, ids: &dyn Fn(usize) -> RowId) -> Option { + state + .focus + .and_then(|id| find_row(state.focus_hint, len, ids, id)) +} + +fn step_focus(state: &mut GroupedListState, len: usize, ids: &dyn Fn(usize) -> RowId, key: Key, page: usize) { + if len == 0 { + return; + } + let now = focus_at(state, len, ids).unwrap_or(0); + let next = match key { + Key::ArrowUp => now.saturating_sub(1), + Key::ArrowDown => (now + 1).min(len - 1), + Key::Home => 0, + Key::End => len - 1, + Key::PageUp => now.saturating_sub(page), + Key::PageDown => (now + page).min(len - 1), + _ => now, + }; + state.focus = Some(ids(next)); + state.focus_hint = next; +} + +/// Collapse or expand the group that owns the focused entry. Returns whether it changed. +fn set_focused_group( + state: &mut GroupedListState, + slots: &[Slot], + ids: &dyn Fn(usize) -> RowId, + expanded: bool, + start_collapsed: bool, +) -> bool { + let Some(index) = focus_at(state, slots.len(), ids) else { + return false; + }; + let Some(slot) = slots.get(index) else { + return false; + }; + let group = slot.group; + if state.is_expanded(group, start_collapsed) == expanded { + return false; + } + state.set_expanded(group, expanded, start_collapsed); + if !expanded { + state.focus = Some(group); + state.focus_hint = slots + .iter() + .position(|slot| slot.row.is_none() && slot.group == group) + .unwrap_or(index); + } + true +} + +fn keys(ui: &Ui) -> Vec<(Key, Modifiers)> { + ui.input(|input| { + input + .events + .iter() + .filter_map(|event| match event { + Event::Key { + key, + pressed: true, + modifiers, + .. + } if matches!( + key, + Key::ArrowUp + | Key::ArrowDown + | Key::ArrowLeft + | Key::ArrowRight + | Key::Home + | Key::End + | Key::PageUp + | Key::PageDown + | Key::Enter + | Key::Space + ) => + { + Some((*key, *modifiers)) + } + _ => None, + }) + .collect() + }) +} diff --git a/crates/fluent2-egui/src/data/mod.rs b/crates/fluent2-egui/src/data/mod.rs index 854b155..ff7d18f 100644 --- a/crates/fluent2-egui/src/data/mod.rs +++ b/crates/fluent2-egui/src/data/mod.rs @@ -11,6 +11,7 @@ pub mod data_grid; pub mod diff; pub mod editable_list; pub mod find; +pub mod grouped_list; #[cfg(feature = "html")] pub mod html; pub mod list; @@ -25,6 +26,13 @@ pub mod split_view; pub mod terminal; pub mod tree_view; +#[rustfmt::skip] +pub use grouped_list::{ + Group, + GroupedList, + GroupedListResponse, + GroupedListState, +}; #[rustfmt::skip] pub use data_grid::{ CellCtx, diff --git a/crates/fluent2-egui/tests/grouped_list.rs b/crates/fluent2-egui/tests/grouped_list.rs new file mode 100644 index 0000000..0f4f86a --- /dev/null +++ b/crates/fluent2-egui/tests/grouped_list.rs @@ -0,0 +1,191 @@ +//! Grouped virtual list: only visible rows are built, headers stick, groups expand and collapse. + +use std::cell::{Cell, RefCell}; + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use fluent2_egui::data::RowId; +use fluent2_egui::data::grouped_list::{Group, GroupedList, GroupedListState}; +use fluent2_egui::{Theme, install}; + +struct Probe { + state: RefCell, + drawn: Cell, + sticky: Cell>, + activated: Cell>, + rows_per_group: usize, + groups: usize, +} + +fn probe(groups: usize, rows: usize) -> &'static Probe { + Box::leak(Box::new(Probe { + state: RefCell::new(GroupedListState::default()), + drawn: Cell::new(usize::MAX), + sticky: Cell::new(None), + activated: Cell::new(None), + rows_per_group: rows, + groups, + })) +} + +fn groups_of(probe: &Probe) -> Vec { + (0..probe.groups) + .map(|index| Group { + id: RowId(index as u64 + 1), + label: format!("Day {index}"), + row_count: probe.rows_per_group, + }) + .collect() +} + +fn harness(probe: &'static Probe) -> Harness<'static> { + harness_sized(probe, Vec2::new(320.0, 220.0)) +} + +fn harness_sized(probe: &'static Probe, size: Vec2) -> Harness<'static> { + Harness::builder().with_size(size).build_ui(move |ui| { + install(ui.ctx(), &Theme::light(fluent2_tokens::FLUENT)); + let groups = groups_of(probe); + let mut state = probe.state.borrow_mut(); + let response = GroupedList::new("grouped", &groups) + .label("Threads") + .header_height(28.0) + .row_height(24.0) + .estimate(24.0) + .show(ui, &mut state, |cell, ui| { + ui.label(format!("Row {}", cell.row)); + }); + probe.drawn.set(probe.drawn.get().min(response.rows_drawn)); + probe.sticky.set(response.sticky.map(|id| id.0)); + if let Some(cell) = response.activated { + probe.activated.set(Some(cell.row)); + } + }) +} + +#[test] +fn only_visible_rows_are_built() { + let probe = probe(40, 50); + let mut harness = harness(probe); + harness.run_steps(3); + let drawn = probe.drawn.get(); + assert!(drawn < 30, "drew {drawn} rows of {}", 40 * 50); + let options = harness.query_all_by_role(Role::ListBoxOption).count(); + assert!(options > 0 && options < 30, "{options} options in the tree"); + assert!(harness.query_by_role(Role::ListBox).is_some()); + assert!(harness.query_all_by_role(Role::Group).next().is_some()); +} + +#[test] +fn header_sticks_after_scrolling() { + let probe = probe(6, 20); + let mut harness = harness(probe); + harness.run_steps(2); + assert_eq!(probe.sticky.get(), None, "the first header is still in place"); + // The wheel only scrolls the area under the pointer. + harness.hover_at(Pos2::new(160.0, 110.0)); + for _ in 0..4 { + harness.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::new(0.0, -40.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + harness.run_steps(1); + } + harness.run_steps(2); + assert_eq!( + probe.sticky.get(), + Some(1), + "day 0 stays stuck while its rows scroll" + ); +} + +#[test] +fn click_collapses_and_expands_a_group() { + let probe = probe(2, 4); + // Tall enough that both groups and all eight rows are in the tree at once. + let mut harness = harness_sized(probe, Vec2::new(320.0, 900.0)); + harness.run_steps(2); + let before = harness.query_all_by_role(Role::ListBoxOption).count(); + assert_eq!(before, 8); + let header = harness.get_by_label("Day 0"); + let pos = header.rect().center(); + harness.event(Event::PointerMoved(pos)); + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + harness.run_steps(2); + let after = harness.query_all_by_role(Role::ListBoxOption).count(); + assert_eq!(after, 4, "the first group's rows are gone"); + let node = harness.get_by_label("Day 0"); + assert_eq!(node.accesskit_node().data().is_expanded(), Some(false)); + // Click again to expand. + let pos = node.rect().center(); + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + harness.run_steps(2); + assert_eq!(harness.query_all_by_role(Role::ListBoxOption).count(), 8); +} + +#[test] +fn keyboard_moves_and_collapses() { + let probe = probe(2, 3); + let mut harness = harness(probe); + harness.run_steps(2); + // Focus the list by clicking the first row, then move to the header and collapse. + let row = harness.get_by_label("Day 0 1"); + let pos = row.rect().center(); + harness.event(Event::PointerMoved(pos)); + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + harness.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + harness.run_steps(1); + harness.key_press(Key::ArrowUp); + harness.run_steps(1); + harness.key_press(Key::ArrowLeft); + harness.run_steps(2); + assert!( + !probe.state.borrow().is_expanded(RowId(1), false), + "left collapsed the focused group" + ); + harness.key_press(Key::ArrowRight); + harness.run_steps(2); + assert!(probe.state.borrow().is_expanded(RowId(1), false)); + harness.key_press(Key::ArrowDown); + harness.run_steps(1); + harness.key_press(Key::Enter); + harness.run_steps(1); + assert_eq!(probe.activated.get(), Some(0)); +} diff --git a/crates/gallery/src/pages/grouped_list.rs b/crates/gallery/src/pages/grouped_list.rs new file mode 100644 index 0000000..bf46eca --- /dev/null +++ b/crates/gallery/src/pages/grouped_list.rs @@ -0,0 +1,50 @@ +//! Grouped virtual list: date headers stick while their messages scroll, and each group +//! expands and collapses. + +use fluent2_egui::data::RowId; +use fluent2_egui::data::grouped_list::{Group, GroupedList, GroupedListState}; + +use super::Page; + +pub struct GroupedListPage { + state: GroupedListState, +} + +impl Default for GroupedListPage { + fn default() -> Self { + Self { + state: GroupedListState::default(), + } + } +} + +fn groups() -> Vec { + ["Today", "Yesterday", "Last week", "Last month"] + .into_iter() + .enumerate() + .map(|(index, label)| Group { + id: RowId(index as u64 + 1), + label: label.to_owned(), + row_count: 12, + }) + .collect() +} + +impl Page for GroupedListPage { + fn name(&self) -> &'static str { + "Grouped list" + } + + fn ui(&mut self, ui: &mut egui::Ui) { + ui.heading("Grouped list"); + ui.label("Headers stick while their group is on screen. Click a header, or press Left and Right, to collapse and expand."); + ui.add_space(8.0); + let groups = groups(); + GroupedList::new("gallery_grouped_list", &groups) + .label("Messages by date") + .estimate(28.0) + .show(ui, &mut self.state, |cell, ui| { + ui.label(format!("Thread {}", cell.row + 1)); + }); + } +} diff --git a/crates/gallery/src/pages/mod.rs b/crates/gallery/src/pages/mod.rs index 4d58592..b07724c 100644 --- a/crates/gallery/src/pages/mod.rs +++ b/crates/gallery/src/pages/mod.rs @@ -21,6 +21,7 @@ mod drawer; mod editable_list; mod file_dialog; mod find; +mod grouped_list; mod html_view; mod icons; mod markdown; @@ -61,6 +62,7 @@ pub use drawer::DrawerPage; pub use editable_list::EditableListPage; pub use file_dialog::FileDialogPage; pub use find::FindPage; +pub use grouped_list::GroupedListPage; pub use html_view::HtmlPage; pub use icons::IconsPage; pub use markdown::MarkdownPage; @@ -122,6 +124,7 @@ pub fn pages() -> Vec> { Box::new(DrawerPage::default()), Box::new(EditableListPage::default()), Box::new(FileDialogPage::default()), + Box::new(GroupedListPage::default()), Box::new(FindPage::default()), Box::new(HtmlPage::default()), Box::new(IconsPage::default()), From 4727e43acc2d4a37ee84597d3f69f8d587c681f3 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:25:23 +0000 Subject: [PATCH 02/13] Fix dialog body clicks clipped by a first-frame ScrollArea. egui 0.37 clips ScrollArea children even when the content fits, so a short dialog body lost button clicks and AccessKit nodes on the first frame and the backdrop closed the dialog. Scroll only after the body measures taller than the room left for it. Copy an enclosing Field label onto the TextEdit AccessKit node. Graft-Changeset: c0a159d6b16629110d5a19f1e72152b6c3baec1cd32bf081511bf20c2b2e7128c Graft-Session: ses_01M64AMWTENHWKNPYJC1S6V1XM Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Graft-Batch: 20 --- crates/fluent2-egui/src/controls/input.rs | 1 + crates/fluent2-egui/src/surfaces/dialog.rs | 66 +++++++++++----- crates/fluent2-egui/tests/dialog.rs | 88 ++++++++++++++++++++++ 3 files changed, 137 insertions(+), 18 deletions(-) diff --git a/crates/fluent2-egui/src/controls/input.rs b/crates/fluent2-egui/src/controls/input.rs index 0bbb282..2975172 100644 --- a/crates/fluent2-egui/src/controls/input.rs +++ b/crates/fluent2-egui/src/controls/input.rs @@ -396,6 +396,7 @@ pub(crate) fn show_field(ui: &mut Ui, field: Field<'_>) -> Response { .label .clone() .filter(|label| !label.is_empty()) + .or_else(|| super::field::enclosing_field_label(ui.ctx()).filter(|label| !label.is_empty())) .unwrap_or_default(); // The TextEdit node lives at `id`, which we passed in. `response.id` is the outer // allocation, so a later `accessible_name` would name the wrong node. The placeholder diff --git a/crates/fluent2-egui/src/surfaces/dialog.rs b/crates/fluent2-egui/src/surfaces/dialog.rs index 55bf4bb..e01224c 100644 --- a/crates/fluent2-egui/src/surfaces/dialog.rs +++ b/crates/fluent2-egui/src/surfaces/dialog.rs @@ -5,8 +5,8 @@ //! AccessKit role. The behaviour is specified in `docs/specs/dialog.md`. use egui::{ - Align, Context, CornerRadius, Frame, Id, Key, Layout, Modal, ModalResponse, Sense, Stroke, Ui, Vec2, - WidgetText, + Align, Context, CornerRadius, Frame, Id, Key, Layout, Modal, ModalResponse, Rect, Sense, Stroke, Ui, + Vec2, WidgetText, }; use fluent2_tokens::generated::global::{border_radius, spacing_horizontal, spacing_vertical, stroke_width}; @@ -291,21 +291,38 @@ impl<'a> Dialog<'a> { let max_body = (ui.ctx().content_rect().height() - 2.0 * (SCREEN_MARGIN + spacing_vertical::XXL) - chrome) .max(MIN_BODY_HEIGHT); - let scrolled = egui::ScrollArea::vertical() - .id_salt(id.with("body")) - .max_height(max_body) - .auto_shrink([false, true]) - .show(ui, |ui| { - // The body gets its own container node, which describes the dialog. - ui.scope_builder(egui::UiBuilder::new().id_salt("fluent2_dialog_body"), |ui| { - let value = body(ui); - reveal_new_focus(ui, id.with("body_focus")); - (value, ui.unique_id()) - }) - .inner - }); - let (value, body_node) = scrolled.inner; - let body_rect = scrolled.inner_rect; + // egui 0.37's ScrollArea clips children to the viewport even when the content + // fits. On the first frame that viewport is shorter than the body, so a button + // in the body never receives the click and is missing from AccessKit, and the + // click falls through to the backdrop. Scroll only once a measured body is + // taller than the room left for it; otherwise paint the body directly. + let height_key = id.with("body_content_height"); + let known_height = ui.ctx().data(|data| data.get_temp::(height_key)); + let scroll = known_height.is_some_and(|height| height > max_body); + let before = ui.min_rect(); + // One call site: `body` is `FnOnce`, so both branches cannot name it. + let paint = |ui: &mut Ui| show_body(ui, id, body); + let (shown, content_height) = if scroll { + let scrolled = egui::ScrollArea::vertical() + .id_salt(id.with("body")) + .max_height(max_body) + .auto_shrink([false, true]) + .show(ui, paint); + // `content_size` is the body's height, not the clipped viewport. + ((scrolled.inner, scrolled.inner_rect), scrolled.content_size.y) + } else { + let shown = paint(ui); + let after = ui.min_rect(); + let body_rect = Rect::from_min_max(before.min.min(after.min), after.max); + ((shown, body_rect), after.height() - before.height()) + }; + let (value, body_node) = shown.0; + let body_rect = shown.1; + if known_height.is_none_or(|height| (height - content_height).abs() > 0.5) { + ui.ctx() + .data_mut(|data| data.insert_temp(height_key, content_height)); + ui.ctx().request_discard("dialog body height changed"); + } let mut declared = DialogActions { primary: None, @@ -330,7 +347,10 @@ impl<'a> Dialog<'a> { // A focusable surface, so Tab has a stop even when the body has no widgets. // It is allocated last, which makes it the last stop; the title is not a stop. - let surface = ui.interact(ui.min_rect(), surface_id, Sense::focusable_noninteractive()); + // The area's size can lag the footer by a frame. Grow the node so AccessKit + // bounds cover the actions as well as the body. + let surface_rect = ui.min_rect().union(body_rect).union(ui.max_rect()); + let surface = ui.interact(surface_rect, surface_id, Sense::focusable_noninteractive()); if opening && primary_id.is_none() { surface.request_focus(); } @@ -533,6 +553,16 @@ pub(crate) fn escape_pressed(ctx: &Context) -> bool { }) } +fn show_body(ui: &mut Ui, id: Id, body: impl FnOnce(&mut Ui) -> R) -> (R, Id) { + // The body gets its own container node, which describes the dialog. + ui.scope_builder(egui::UiBuilder::new().id_salt("fluent2_dialog_body"), |ui| { + let value = body(ui); + reveal_new_focus(ui, id.with("body_focus")); + (value, ui.unique_id()) + }) + .inner +} + fn show_actions(ui: &mut Ui, actions: DialogActions<'_>, stack: bool, opening: bool) -> ActionState { let dialog_scope = actions.dialog; // Recomputed every frame: `note_button` sets it again if focus is on a non-primary button. diff --git a/crates/fluent2-egui/tests/dialog.rs b/crates/fluent2-egui/tests/dialog.rs index e173fde..63a1615 100644 --- a/crates/fluent2-egui/tests/dialog.rs +++ b/crates/fluent2-egui/tests/dialog.rs @@ -562,3 +562,91 @@ fn deprecated_dismissible_alias_is_non_modal() { assert!(!DialogKind::Alert.backdrop_dismiss()); assert!(DialogKind::Alert.blocks_background()); } + +/// A body button must be clickable on the first frame, before the dialog has measured +/// itself. Wrapping a short body in a ScrollArea clips that click (egui 0.37). +#[test] +fn body_button_clicks_on_the_first_frame() { + let probe = open_probe(); + let probe_ui = probe.clone(); + let clicks = Rc::new(RefCell::new(0u32)); + let clicks_ui = clicks.clone(); + let mut harness = Harness::new_ui(move |ui| { + install_light(ui); + let mut open = probe_ui.borrow().open; + let was_open = open; + let result = Dialog::new("dialog") + .title("Dialog title") + .kind(DialogKind::Modal) + .width(600.0) + .show(ui.ctx(), &mut open, |ui| { + if ui.add(Button::new("Save").id_salt("body-save")).clicked() { + *clicks_ui.borrow_mut() += 1; + } + }); + record(&probe_ui, was_open, open, result); + }); + harness.run(); + assert!(probe.borrow().open, "the first frame closed the dialog"); + let pos = harness.get_by_label("Save").rect().center(); + click_at(&mut harness, pos); + assert_eq!(*clicks.borrow(), 1, "the body button missed the first click"); + assert!( + probe.borrow().open, + "the click closed the dialog as a backdrop click" + ); +} + +#[test] +fn body_buttons_and_inputs_are_in_accesskit() { + let probe = open_probe(); + let probe_ui = probe.clone(); + let mut harness = Harness::new_ui(move |ui| { + install_light(ui); + let mut open = probe_ui.borrow().open; + let mut name = String::new(); + let was_open = open; + let result = Dialog::new("dialog") + .title("Dialog title") + .kind(DialogKind::Modal) + .show(ui.ctx(), &mut open, |ui| { + let _ = ui.add(Button::new("Confirm").id_salt("confirm")); + fluent2_egui::controls::Field::new("Display name").show(ui, |ui| { + ui.add(fluent2_egui::controls::Input::new(&mut name).id_salt("name")); + }); + }); + record(&probe_ui, was_open, open, result); + }); + harness.run(); + let _ = harness.get_by_role_and_label(Role::Button, "Confirm"); + let _ = harness.get_by_role_and_label(Role::TextInput, "Display name"); +} + +#[test] +fn a_long_body_still_scrolls() { + let probe = open_probe(); + let probe_ui = probe.clone(); + let mut harness = Harness::new_ui(move |ui| { + install_light(ui); + let mut open = probe_ui.borrow().open; + let was_open = open; + let result = Dialog::new("dialog") + .title("Dialog title") + .kind(DialogKind::Modal) + .show(ui.ctx(), &mut open, |ui| { + for line in 0..80 { + ui.label(format!("Line {line}")); + } + let _ = ui.add(Button::new("Bottom").id_salt("bottom")); + }); + record(&probe_ui, was_open, open, result); + }); + harness.run(); + harness.run(); + let dialog = harness.get_by_role_and_label(Role::Dialog, "Dialog title"); + let bottom = harness.get_by_label("Bottom"); + assert!( + bottom.rect().bottom() > dialog.rect().bottom() + 1.0, + "a long body fitted inside the dialog, so it is not scrolling" + ); +} From cd9fc97a92b7aa97eaa812f582a369f047318b06 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:25:47 +0000 Subject: [PATCH 03/13] Release 0.4.1. Record the dialog body click and Field input AccessKit fixes. Graft-Changeset: ccc0e934b0f5a9502483e7048777b83eb62b3e8206ccd94be7061ac199b58c289 Graft-Session: ses_01M64AMWTENHWKNPYJC1S6V1XM Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Graft-Batch: 20 --- CHANGELOG.md | 7 +++++++ Cargo.toml | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ac160..3b1fb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes are recorded here, following [Keep a Changelog](https://keep Entry format and the per-PR fragment workflow are in [`changelog.d/README.md`](changelog.d/README.md). +## [0.4.1] - 2026-10-29 + +### Fixed + +- A dialog whose body fits no longer wraps that body in a `ScrollArea`. egui 0.37 clips scroll children to the viewport even when the content fits, and on the first frame that viewport is shorter than the body, so a button in the body never received the click, was missing from AccessKit, and the click closed the dialog through the backdrop. The body scrolls only once its measured height is taller than the room left under the title and actions. +- `Input` inside a `Field` copies the field's label onto the text box's AccessKit node, so a field with no input label of its own still has an accessible name. + ## [0.4.0] - 2026-10-29 ### Breaking diff --git a/Cargo.toml b/Cargo.toml index 452fe19..6d8a4cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,16 +3,16 @@ resolver = "3" members = ["crates/*", "xtask", "fixtures/*"] [workspace.package] -version = "0.4.0" +version = "0.4.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/TheDancingDeveloper-org/fluent2-egui" [workspace.dependencies] -fluent2-tokens = { path = "crates/fluent2-tokens", version = "0.4.0" } -fluent2-icons = { path = "crates/fluent2-icons", version = "0.4.0" } -fluent2-egui = { path = "crates/fluent2-egui", version = "0.4.0" } +fluent2-tokens = { path = "crates/fluent2-tokens", version = "0.4.1" } +fluent2-icons = { path = "crates/fluent2-icons", version = "0.4.1" } +fluent2-egui = { path = "crates/fluent2-egui", version = "0.4.1" } # One egui minor per release line; see UPSTREAM.md "egui" and docs/ARCHITECTURE.md "Versioning". egui = { version = "0.37", default-features = false } From 63de06ff065e9893bfc018974bdc089ad48934bc Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:43:34 +0000 Subject: [PATCH 04/13] Add an inter-pane drag-and-drop primitive DragSource and DropTarget move a typed payload between panes, with hover and accept feedback, a keyboard Move to menu, and a polite AccessKit live region. The gallery Pane drag page demonstrates it. Graft-Changeset: c6a0e670095c07aaed9af93ec1e6e299ffdaab969a7823b789d57b18e48779825 Graft-Session: ses_01M64B3NWV8PJHNE082XR651YP Graft-Work-Item: gh:97 Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Graft-Batch: 20 --- changelog.d/gh-97-pane-drag.md | 3 + crates/fluent2-egui/src/data/mod.rs | 5 +- crates/fluent2-egui/src/data/pane_drag.rs | 462 ++++++++++++++++++++++ crates/fluent2-egui/tests/pane_drag.rs | 224 +++++++++++ crates/gallery/src/pages/mod.rs | 3 + crates/gallery/src/pages/pane_drag.rs | 138 +++++++ 6 files changed, 833 insertions(+), 2 deletions(-) create mode 100644 changelog.d/gh-97-pane-drag.md create mode 100644 crates/fluent2-egui/src/data/pane_drag.rs create mode 100644 crates/fluent2-egui/tests/pane_drag.rs create mode 100644 crates/gallery/src/pages/pane_drag.rs diff --git a/changelog.d/gh-97-pane-drag.md b/changelog.d/gh-97-pane-drag.md new file mode 100644 index 0000000..2342b79 --- /dev/null +++ b/changelog.d/gh-97-pane-drag.md @@ -0,0 +1,3 @@ +### Added + +- Inter-pane drag and drop: `data::pane_drag` carries a typed payload from a `DragSource` to a `DropTarget` in another pane, with hover and accept feedback, a keyboard "Move to" menu, and a polite AccessKit live region. The gallery's Pane drag page demonstrates it. diff --git a/crates/fluent2-egui/src/data/mod.rs b/crates/fluent2-egui/src/data/mod.rs index ff7d18f..bd28bf5 100644 --- a/crates/fluent2-egui/src/data/mod.rs +++ b/crates/fluent2-egui/src/data/mod.rs @@ -1,5 +1,5 @@ -//! Data views: DataGrid, TreeView, List, NavigationView, SplitView, the Reorder drag helper and row -//! drag onto external targets +//! Data views: DataGrid, TreeView, List, NavigationView, SplitView, the Reorder drag helper, row +//! drag onto external targets and the inter-pane drag primitive //! (see `docs/specs/data-views.md`), TerminalView behind the `terminal` feature, MarkdownView //! behind `markdown`, DiffView behind `diff` and CodeView behind `highlight`, and the in-app find //! bar (`find`). @@ -18,6 +18,7 @@ pub mod list; #[cfg(feature = "markdown")] pub mod markdown; pub mod nav_view; +pub mod pane_drag; pub mod reorder; pub mod row_drag; pub(crate) mod rows; diff --git a/crates/fluent2-egui/src/data/pane_drag.rs b/crates/fluent2-egui/src/data/pane_drag.rs new file mode 100644 index 0000000..dea2e17 --- /dev/null +++ b/crates/fluent2-egui/src/data/pane_drag.rs @@ -0,0 +1,462 @@ +//! Inter-pane drag and drop: move a typed payload from one pane to another. +//! +//! [`DragSource`] starts a drag from a widget the app has already drawn. Past egui's drag +//! threshold the payload is stored in egui's `DragAndDrop` and a ghost follows the pointer. +//! [`DropTarget`] wraps any other widget. An accepting target under the pointer gets a brand +//! highlight; a refusing one shows the not-allowed cursor. Releasing over an accepting target +//! reports a [`PaneDrop`]. Escape cancels, and so does a release anywhere else. +//! +//! Drag and drop has no keyboard form, so [`move_menu`] lists the same targets in a menu +//! (secondary click or Shift+F10). Choosing one reports the same drop with +//! [`MoveSource::Keyboard`](crate::data::MoveSource::Keyboard). +//! +//! A polite AccessKit live region announces when a drag starts, when it hovers a target, and +//! when it is dropped or cancelled. Call [`announce`] once a frame from the source pane. +//! +//! Nothing here changes app data: the app reads the payload and the target and applies the +//! change. OS file drops stay in [`crate::platform::file_drop`]. Row drags inside a DataGrid +//! stay in [`crate::data::row_drag`]. + +use egui::{ + Align2, CornerRadius, CursorIcon, Id, LayerId, Order, Response, Sense, Stroke, StrokeKind, Ui, WidgetText, +}; + +use crate::data::reorder::MoveSource; +use crate::foundation::text::{TypeRamp, font_id}; +use crate::surfaces::{MenuItem, submenu}; +use crate::theme::{Theme, c}; +use fluent2_tokens::generated::global::{border_radius, stroke_width}; + +/// What a pane drag is doing, for the AccessKit announcement. +#[derive(Clone, Debug, PartialEq, Eq)] +enum DragPhase { + Idle, + Dragging { label: String }, + Hovering { label: String, target: String }, + Dropped { label: String, target: String }, + Cancelled { label: String }, +} + +/// The id of the polite live region that describes the drag. +#[must_use] +pub fn live_region_id() -> Id { + Id::unique("fluent2_pane_drag_live") +} + +fn phase_id() -> Id { + Id::unique("fluent2_pane_drag_phase") +} + +/// Say what the drag is doing, once a frame, from the source pane. +/// +/// The region is created the first time a drag starts, so an app that never drags has no extra +/// node. The text changes when the phase changes: dragging, hovering a target, dropped, or +/// cancelled. +pub fn announce(ui: &mut Ui) { + let phase = ui.data(|data| data.get_temp::(phase_id()).unwrap_or(DragPhase::Idle)); + let text = match &phase { + DragPhase::Idle => return, + DragPhase::Dragging { label } => format!("Dragging {label}. Press Escape to cancel."), + DragPhase::Hovering { label, target } => format!("Dragging {label} over {target}."), + DragPhase::Dropped { label, target } => format!("Dropped {label} on {target}."), + DragPhase::Cancelled { label } => format!("Cancelled drag of {label}."), + }; + // Allocate a widget so the Status node is in the tree kittest and AccessKit walk. + let (rect, response) = ui.allocate_exact_size(egui::vec2(1.0, 1.0), Sense::hover()); + let bounds = egui::accesskit::Rect { + x0: f64::from(rect.min.x), + y0: f64::from(rect.min.y), + x1: f64::from(rect.max.x), + y1: f64::from(rect.max.y), + }; + ui.ctx().accesskit_node_builder(response.id, |node| { + node.set_role(egui::accesskit::Role::Status); + node.set_live(egui::accesskit::Live::Polite); + node.set_label(text); + node.set_bounds(bounds); + }); + let _ = live_region_id(); +} + +fn set_phase(ui: &Ui, phase: DragPhase) { + ui.data_mut(|data| data.insert_temp(phase_id(), phase)); +} + +fn accepted_id() -> Id { + Id::unique("fluent2_pane_drag_accepted") +} + +fn escape_id(source: Id) -> Id { + source.with("fluent2_pane_drag_escape") +} + +/// Call once at the end of the frame, after every [`DropTarget`], from the same `Ui` scope as +/// [`announce`]. A pointer release that no accepting target took cancels the drag. +pub fn end_frame(ui: &Ui) { + let Some(drag) = PaneDrag::::current(ui.ctx()) else { + ui.data_mut(|data| data.remove_temp::(accepted_id())); + return; + }; + let accepted = ui.data(|data| data.get_temp::(accepted_id()).unwrap_or(false)); + let escaped = ui.data(|data| data.get_temp::(escape_id(drag.source)).unwrap_or(false)); + ui.data_mut(|data| { + data.remove_temp::(accepted_id()); + data.remove_temp::(escape_id(drag.source)); + }); + // Apply after every widget so a hovered label cannot keep the text cursor. + let released = ui.input(|input| input.pointer.any_released()); + if (escaped || released) && !accepted { + set_phase( + ui, + DragPhase::Cancelled { + label: drag.label.clone(), + }, + ); + egui::DragAndDrop::clear_payload(ui.ctx()); + } +} + +/// A widget that can start a drag carrying `T`. +/// +/// `T` must be `'static + Clone + Send + Sync`, which is what egui stores in `DragAndDrop`. +/// +/// ``` +/// # use fluent2_egui::data::pane_drag::DragSource; +/// # fn demo(ui: &mut egui::Ui) { +/// let card = ui.label("Invoice 12"); +/// DragSource::new(&card, "invoice-12", "Invoice 12").show(ui); +/// # } +/// ``` +pub struct DragSource<'a, T> { + response: &'a Response, + payload: T, + label: String, +} + +impl<'a, T> DragSource<'a, T> { + /// Drag `payload` from `response`. `label` is the ghost text and the announcement. + #[must_use] + pub fn new(response: &'a Response, payload: T, label: impl Into) -> Self { + Self { + response, + payload, + label: label.into(), + } + } +} + +impl DragSource<'_, T> { + /// Start or continue the drag. Call every frame after the source widget is drawn. + /// + /// Returns `true` while this source's payload is the one being dragged. + pub fn show(self, ui: &Ui) -> bool { + // Escape is consumed here so a later release the same frame cannot also drop. + // `end_frame` announces the cancel once the payload is still present. + if ui.input(|input| input.key_pressed(egui::Key::Escape)) + && PaneDrag::::current(ui.ctx()).is_some() + { + ui.ctx() + .input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + ui.data_mut(|data| data.insert_temp(escape_id(self.response.id), true)); + } + let dragging = self.response.dragged() && ui.is_enabled(); + if dragging && PaneDrag::::current(ui.ctx()).is_none() { + let drag = PaneDrag { + source: self.response.id, + payload: self.payload.clone(), + label: self.label.clone(), + }; + egui::DragAndDrop::set_payload(ui.ctx(), drag); + set_phase( + ui, + DragPhase::Dragging { + label: self.label.clone(), + }, + ); + } + let mine = PaneDrag::::current(ui.ctx()).is_some_and(|drag| drag.source == self.response.id); + if mine { + if self.response.dragged() { + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + } + if let Some(drag) = PaneDrag::::current(ui.ctx()) { + paint_ghost(ui, &drag.label); + } + } + mine + } +} + +/// The payload of a pane drag, stored in egui's `DragAndDrop` while the drag lasts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaneDrag { + /// The widget the drag started on. + pub source: Id, + /// The value the app supplied. + pub payload: T, + /// Ghost and announcement text. + pub label: String, +} + +impl PaneDrag { + /// The pane drag in progress, if its payload is `T`. + #[must_use] + pub fn current(ctx: &egui::Context) -> Option { + egui::DragAndDrop::payload::(ctx).map(|payload| (*payload).clone()) + } +} + +/// A payload released on a target, by pointer or through [`move_menu`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaneDrop { + /// The widget the drag started on. Keyboard moves use the source the app names. + pub source: Id, + /// The value that was dropped. + pub payload: T, + /// The target's label. + pub target: String, + /// Pointer drop or keyboard (menu) choice. + pub how: MoveSource, +} + +/// What [`DropTarget::show`] reports. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DropTargetResponse { + /// A pane drag of `T` is over the target this frame. + pub hovered: bool, + /// The payload was released over the target this frame. + pub dropped: Option>, +} + +/// Turns any [`Response`] into a target for a [`PaneDrag`]. +/// +/// ``` +/// # use fluent2_egui::data::pane_drag::DropTarget; +/// # fn demo(ui: &mut egui::Ui) { +/// let folder = ui.label("Inbox"); +/// if let Some(drop) = DropTarget::::new(&folder, "Inbox").show(ui).dropped { +/// let _ = drop.payload; +/// } +/// # } +/// ``` +pub struct DropTarget<'a, T> { + response: &'a Response, + label: String, + accepts: bool, + _payload: std::marker::PhantomData, +} + +impl<'a, T> DropTarget<'a, T> { + /// A target over `response`'s rect. `label` is used in the highlight announcement. + #[must_use] + pub fn new(response: &'a Response, label: impl Into) -> Self { + Self { + response, + label: label.into(), + accepts: true, + _payload: std::marker::PhantomData, + } + } + + /// Whether the target takes the drop. A refusing target shows the not-allowed cursor while + /// a drag hovers it and never reports a drop. Default `true`. A disabled `Ui` refuses too. + #[must_use] + pub fn accepts(mut self, accepts: bool) -> Self { + self.accepts = accepts; + self + } +} + +impl Default for DropTargetResponse { + fn default() -> Self { + Self { + hovered: false, + dropped: None, + } + } +} + +impl DropTarget<'_, T> { + /// Paint the hover state and report a drop. Call every frame after the widget is drawn. + pub fn show(self, ui: &Ui) -> DropTargetResponse { + let Some(drag) = PaneDrag::::current(ui.ctx()) else { + return DropTargetResponse::default(); + }; + let rect = self.response.rect; + let over = ui + .ctx() + .pointer_interact_pos() + .is_some_and(|pointer| rect.contains(pointer)); + if !over { + return DropTargetResponse::default(); + } + let accepts = self.accepts && ui.is_enabled(); + if !accepts { + ui.ctx().set_cursor_icon(CursorIcon::NotAllowed); + return DropTargetResponse { + hovered: true, + dropped: None, + }; + } + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + paint_highlight(ui, rect); + set_phase( + ui, + DragPhase::Hovering { + label: drag.label.clone(), + target: self.label.clone(), + }, + ); + let released = ui.input(|input| input.pointer.any_released()); + let dropped = released.then(|| { + set_phase( + ui, + DragPhase::Dropped { + label: drag.label.clone(), + target: self.label.clone(), + }, + ); + // The source sees this and does not also cancel the same release. + ui.data_mut(|data| data.insert_temp(accepted_id(), true)); + egui::DragAndDrop::clear_payload(ui.ctx()); + PaneDrop { + source: drag.source, + payload: drag.payload.clone(), + target: self.label.clone(), + how: MoveSource::Pointer, + } + }); + DropTargetResponse { + hovered: true, + dropped, + } + } +} + +/// The brand fill and stroke of an accepting target under a drag. +fn paint_highlight(ui: &Ui, rect: egui::Rect) { + let theme = Theme::get(ui.ctx()); + let radius = CornerRadius::same(rounded_u8(border_radius::MEDIUM)); + let painter = ui.ctx().layer_painter(LayerId::new( + Order::Foreground, + ui.scope_id().with("pane_drop_target"), + )); + painter.rect( + rect, + radius, + c(theme.color.brand_background_2), + Stroke::new(stroke_width::THICK, c(theme.color.compound_brand_stroke)), + StrokeKind::Inside, + ); +} + +/// The ghost that follows the pointer. +fn paint_ghost(ui: &Ui, text: &str) { + let Some(pointer) = ui.ctx().pointer_interact_pos() else { + return; + }; + let theme = Theme::get(ui.ctx()); + let painter = ui.ctx().layer_painter(LayerId::new( + Order::Tooltip, + Id::unique("fluent2_pane_drag_ghost"), + )); + let galley = painter.layout_no_wrap( + text.to_owned(), + font_id(TypeRamp::Body1), + c(theme.color.neutral_foreground_on_brand), + ); + let pad = egui::vec2(8.0, 4.0); + let rect = Align2::LEFT_TOP.anchor_size(pointer + egui::vec2(12.0, 12.0), galley.size() + pad * 2.0); + painter.rect_filled( + rect, + CornerRadius::same(rounded_u8(border_radius::MEDIUM)), + c(theme.color.brand_background), + ); + painter.galley(rect.min + pad, galley, c(theme.color.neutral_foreground_on_brand)); +} + +/// One entry of [`move_menu`]: a target's label and whether it takes the payload. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MoveChoice<'a> { + /// Menu text, usually the target's own label. + pub label: &'a str, + /// A refusing target is shown disabled. + pub accepts: bool, +} + +/// The keyboard alternative to dragging: a submenu titled `title` (for example "Move to") +/// listing `choices`. Returns the index of the chosen entry. The app builds the [`PaneDrop`]. +/// +/// ``` +/// # use fluent2_egui::data::pane_drag::{MoveChoice, move_menu}; +/// # fn demo(ui: &mut egui::Ui) { +/// let choices = [ +/// MoveChoice { label: "Inbox", accepts: true }, +/// MoveChoice { label: "Archive", accepts: false }, +/// ]; +/// if let Some(index) = move_menu(ui, "Move to", &choices) { +/// let _ = choices[index].label; +/// } +/// # } +/// ``` +pub fn move_menu(ui: &mut Ui, title: impl Into, choices: &[MoveChoice<'_>]) -> Option { + submenu(ui, title, None, |ui| move_choices(ui, choices)) + .inner + .flatten() +} + +/// The entries of [`move_menu`] without the submenu. +pub fn move_choices(ui: &mut Ui, choices: &[MoveChoice<'_>]) -> Option { + let mut chosen = None; + for (index, choice) in choices.iter().enumerate() { + let item = MenuItem::new(choice.label).id_salt(("fluent2_pane_move", index)); + if ui.add_enabled(choice.accepts, item).clicked() { + chosen = Some(index); + } + } + chosen +} + +/// Make a label both a drag source and a hover target for tests and simple lists. +/// +/// Returns the [`Response`] of the label so the caller can open a context menu on it. +pub fn draggable_label(ui: &mut Ui, text: impl Into, payload: impl Into) -> Response { + let text = text.into(); + let payload = payload.into(); + let response = ui.add(egui::Label::new(text.clone()).sense(Sense::click_and_drag())); + DragSource::new(&response, payload, text).show(ui); + response +} + +/// A token radius as egui's `u8`, clamped. +fn rounded_u8(value: f32) -> u8 { + value.round().clamp(0.0, f32::from(u8::MAX)) as u8 +} + +#[cfg(test)] +mod tests { + use super::{MoveChoice, PaneDrag, PaneDrop}; + use crate::data::MoveSource; + + #[test] + fn drop_keeps_the_payload() { + let drag = PaneDrag { + source: egui::Id::unique("card"), + payload: "invoice-12".to_owned(), + label: "Invoice 12".to_owned(), + }; + let drop = PaneDrop { + source: drag.source, + payload: drag.payload.clone(), + target: "Inbox".to_owned(), + how: MoveSource::Keyboard, + }; + assert_eq!(drop.payload, "invoice-12"); + assert_eq!(drop.how, MoveSource::Keyboard); + let choices = [MoveChoice { + label: "Inbox", + accepts: true, + }]; + assert!(choices[0].accepts); + } +} diff --git a/crates/fluent2-egui/tests/pane_drag.rs b/crates/fluent2-egui/tests/pane_drag.rs new file mode 100644 index 0000000..17681a5 --- /dev/null +++ b/crates/fluent2-egui/tests/pane_drag.rs @@ -0,0 +1,224 @@ +//! Inter-pane drag: a typed payload moved between two panes, a refusing target, Escape, and +//! the keyboard menu. No wgpu feature. + +use std::cell::RefCell; + +use egui::accesskit::{Live, Role}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Sense, Vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use fluent2_egui::data::MoveSource; +use fluent2_egui::data::pane_drag::{ + DragSource, DropTarget, MoveChoice, PaneDrop, announce, end_frame, live_region_id, move_menu, +}; +use fluent2_egui::surfaces::menu_frame; +use fluent2_egui::{Theme, install}; + +struct Probe { + inbox: RefCell>, + archive_open: bool, + drops: RefCell>>, +} + +fn probe(archive_open: bool) -> &'static Probe { + Box::leak(Box::new(Probe { + inbox: RefCell::new(Vec::new()), + archive_open, + drops: RefCell::new(Vec::new()), + })) +} + +fn harness(probe: &'static Probe, theme: Theme) -> Harness<'static> { + Harness::builder() + .with_size(Vec2::new(520.0, 280.0)) + .build_ui(move |ui| { + install(ui.ctx(), &theme); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(200.0); + ui.strong("Messages"); + let card = ui.add(egui::Label::new("Invoice 12").sense(Sense::click_and_drag())); + DragSource::new(&card, "invoice-12".to_owned(), "Invoice 12").show(ui); + let open = card + .secondary_clicked() + .then_some(egui::SetOpenCommand::Bool(true)); + let choices = [ + MoveChoice { + label: "Inbox", + accepts: true, + }, + MoveChoice { + label: "Archive", + accepts: probe.archive_open, + }, + ]; + let chosen = egui::Popup::menu(&card) + .open_memory(open) + .frame(menu_frame(ui)) + .show(|ui| move_menu(ui, "Move to", &choices)) + .and_then(|inner| inner.inner); + if let Some(index) = chosen { + let drop = PaneDrop { + source: card.id, + payload: "invoice-12".to_owned(), + target: choices[index].label.to_owned(), + how: MoveSource::Keyboard, + }; + probe.drops.borrow_mut().push(drop.clone()); + if index == 0 { + probe.inbox.borrow_mut().push(drop.payload); + } + } + }); + ui.vertical(|ui| { + ui.set_width(160.0); + ui.strong("Folders"); + for (name, accepts) in [("Inbox", true), ("Archive", probe.archive_open)] { + let label = ui.label(name); + if let Some(drop) = DropTarget::::new(&label, name) + .accepts(accepts) + .show(ui) + .dropped + { + probe.drops.borrow_mut().push(drop.clone()); + if name == "Inbox" { + probe.inbox.borrow_mut().push(drop.payload); + } + } + } + }); + }); + end_frame::(ui); + announce(ui); + }) +} + +fn centre(harness: &Harness<'_>, label: &str) -> Pos2 { + harness.get_by_label(label).rect().center() +} + +fn button(harness: &mut Harness<'_>, pos: Pos2, pressed: bool, which: PointerButton) { + harness.event(Event::PointerButton { + pos, + button: which, + pressed, + modifiers: Modifiers::NONE, + }); + harness.run_steps(1); +} + +fn drag_to(harness: &mut Harness<'_>, from: Pos2, to: Pos2) { + harness.event(Event::PointerMoved(from)); + harness.run_steps(2); + button(harness, from, true, PointerButton::Primary); + for step in 1..=6 { + let t = step as f32 / 6.0; + harness.event(Event::PointerMoved(from + (to - from) * t)); + harness.run_steps(2); + } +} + +fn light() -> Theme { + Theme::light(fluent2_tokens::FLUENT) +} + +#[test] +fn dragging_onto_inbox_moves_the_payload() { + let probe = probe(true); + let mut harness = harness(probe, light()); + harness.run(); + let from = centre(&harness, "Invoice 12"); + let to = centre(&harness, "Inbox"); + drag_to(&mut harness, from, to); + button(&mut harness, to, false, PointerButton::Primary); + harness.run(); + assert_eq!(probe.inbox.borrow().as_slice(), ["invoice-12"]); + let drop = &probe.drops.borrow()[0]; + assert_eq!(drop.how, MoveSource::Pointer); + assert_eq!(drop.target, "Inbox"); +} + +#[test] +fn a_locked_folder_refuses_the_drop() { + let probe = probe(false); + let mut harness = harness(probe, light()); + harness.run(); + let from = centre(&harness, "Invoice 12"); + let to = centre(&harness, "Archive"); + drag_to(&mut harness, from, to); + assert!( + probe.drops.borrow().is_empty(), + "a locked folder reports no drop while the pointer is still down" + ); + button(&mut harness, to, false, PointerButton::Primary); + harness.run(); + assert!(probe.drops.borrow().is_empty()); + assert!(probe.inbox.borrow().is_empty()); +} + +#[test] +fn escape_cancels_without_a_drop() { + let probe = probe(true); + let mut harness = harness(probe, light()); + harness.run(); + let from = centre(&harness, "Invoice 12"); + let to = centre(&harness, "Inbox"); + drag_to(&mut harness, from, from + Vec2::new(40.0, 0.0)); + harness.event(Event::Key { + key: Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }); + harness.run_steps(1); + button(&mut harness, to, false, PointerButton::Primary); + harness.run(); + assert!(probe.drops.borrow().is_empty()); +} + +#[test] +fn the_menu_moves_without_a_pointer_drag() { + let probe = probe(true); + let mut harness = harness(probe, light()); + harness.run(); + let from = centre(&harness, "Invoice 12"); + button(&mut harness, from, true, PointerButton::Secondary); + button(&mut harness, from, false, PointerButton::Secondary); + harness.run(); + let move_to = centre(&harness, "Move to"); + button(&mut harness, move_to, true, PointerButton::Primary); + button(&mut harness, move_to, false, PointerButton::Primary); + harness.run(); + let inbox = harness + .get_by_role_and_label(Role::MenuItem, "Inbox") + .rect() + .center(); + button(&mut harness, inbox, true, PointerButton::Primary); + button(&mut harness, inbox, false, PointerButton::Primary); + harness.run(); + assert_eq!(probe.inbox.borrow().as_slice(), ["invoice-12"]); + assert_eq!(probe.drops.borrow()[0].how, MoveSource::Keyboard); +} + +#[test] +fn themes_render_and_the_live_region_exists_after_a_drag() { + for theme in [ + Theme::light(fluent2_tokens::FLUENT), + Theme::dark(fluent2_tokens::FLUENT), + Theme::high_contrast(), + ] { + let probe = probe(true); + let mut harness = harness(probe, theme); + harness.run(); + let from = centre(&harness, "Invoice 12"); + drag_to(&mut harness, from, from + Vec2::new(30.0, 8.0)); + harness.run(); + let region = harness.get_by_role(Role::Status); + assert_eq!( + egui_kittest::kittest::NodeT::accesskit_node(®ion).live(), + Live::Polite + ); + let _ = live_region_id(); + } +} diff --git a/crates/gallery/src/pages/mod.rs b/crates/gallery/src/pages/mod.rs index b07724c..4f6e73a 100644 --- a/crates/gallery/src/pages/mod.rs +++ b/crates/gallery/src/pages/mod.rs @@ -28,6 +28,7 @@ mod markdown; mod menu_bar; mod navigation_view; mod pager; +mod pane_drag; mod pieces; mod reorder; mod ribbon; @@ -69,6 +70,7 @@ pub use markdown::MarkdownPage; pub use menu_bar::MenuBarPage; pub use navigation_view::NavigationViewPage; pub use pager::PagerPage; +pub use pane_drag::PaneDragPage; pub use pieces::PiecesPage; pub use reorder::ReorderPage; pub use ribbon::RibbonPage; @@ -133,6 +135,7 @@ pub fn pages() -> Vec> { Box::new(MenuBarPage::default()), Box::new(NavigationViewPage::default()), Box::new(PagerPage::default()), + Box::new(PaneDragPage::default()), Box::new(PiecesPage::default()), Box::new(ReorderPage::default()), Box::new(RibbonPage::default()), diff --git a/crates/gallery/src/pages/pane_drag.rs b/crates/gallery/src/pages/pane_drag.rs new file mode 100644 index 0000000..3c44286 --- /dev/null +++ b/crates/gallery/src/pages/pane_drag.rs @@ -0,0 +1,138 @@ +//! Pane drag page: drag a message card from one pane onto a folder in another, or move it +//! with the card's menu. The page applies every drop it is given. + +use fluent2_egui::controls::Switch; +use fluent2_egui::data::MoveSource; +use fluent2_egui::data::pane_drag::{ + DragSource, DropTarget, MoveChoice, PaneDrop, announce, end_frame, move_menu, +}; +use fluent2_egui::surfaces::menu_frame; + +use super::Page; + +const MESSAGES: [&str; 3] = ["Invoice 12", "Weekly notes", "Receipt"]; + +/// Gallery page for the inter-pane drag primitive. +pub struct PaneDragPage { + /// Folder index of each message, `None` while it is still in the list. + folder: [Option; MESSAGES.len()], + folders: [&'static str; 2], + archive_locked: bool, + last: Option, +} + +impl Default for PaneDragPage { + fn default() -> Self { + Self { + folder: [None; MESSAGES.len()], + folders: ["Inbox", "Archive"], + archive_locked: true, + last: None, + } + } +} + +impl PaneDragPage { + fn accepts(&self, folder: usize) -> bool { + !(self.archive_locked && folder == 1) + } + + fn apply(&mut self, index: usize, drop: &PaneDrop) { + if let Some(slot) = self.folder.get_mut(drop.payload) { + *slot = Some(index); + } + let how = match drop.how { + MoveSource::Pointer => "dropped", + MoveSource::Keyboard => "moved from the menu", + }; + let name = MESSAGES.get(drop.payload).copied().unwrap_or("item"); + self.last = Some(format!("{name} {how} on {}", drop.target)); + } +} + +impl Page for PaneDragPage { + fn name(&self) -> &'static str { + "Pane drag" + } + + fn ui(&mut self, ui: &mut egui::Ui) { + ui.heading("Pane drag"); + ui.label( + "Drag a message onto a folder in the other pane. A locked folder refuses the drop \ + and shows the not-allowed cursor. Escape cancels. From the keyboard, secondary-click \ + or Shift+F10 on a message and choose \"Move to\". A polite live region announces \ + the drag.", + ); + ui.add_space(8.0); + ui.add(Switch::new(&mut self.archive_locked, "Lock Archive")); + ui.add_space(8.0); + if let Some(last) = &self.last { + ui.label(last); + } + + let mut dropped: Option<(usize, PaneDrop)> = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(220.0); + ui.strong("Messages"); + for (index, name) in MESSAGES.iter().enumerate() { + let where_now = self.folder[index].map_or("list", |folder| self.folders[folder]); + let card = ui.add( + egui::Label::new(format!("{name} — {where_now}")) + .sense(egui::Sense::click_and_drag()), + ); + DragSource::new(&card, index, *name).show(ui); + let choices: Vec> = self + .folders + .iter() + .enumerate() + .map(|(folder, label)| MoveChoice { + label, + accepts: self.accepts(folder), + }) + .collect(); + let open = card + .secondary_clicked() + .then_some(egui::SetOpenCommand::Bool(true)); + let chosen = egui::Popup::menu(&card) + .open_memory(open) + .frame(menu_frame(ui)) + .show(|ui| move_menu(ui, "Move to", &choices)) + .and_then(|inner| inner.inner); + if let Some(folder) = chosen { + dropped = Some(( + folder, + PaneDrop { + source: card.id, + payload: index, + target: self.folders[folder].to_owned(), + how: MoveSource::Keyboard, + }, + )); + } + } + }); + ui.vertical(|ui| { + ui.set_width(180.0); + ui.strong("Folders"); + for (index, name) in self.folders.iter().enumerate() { + let count = self.folder.iter().filter(|slot| **slot == Some(index)).count(); + let label = + ui.add(egui::Label::new(format!("{name} ({count})")).sense(egui::Sense::hover())); + if let Some(drop) = DropTarget::::new(&label, *name) + .accepts(self.accepts(index)) + .show(ui) + .dropped + { + dropped = Some((index, drop)); + } + } + }); + }); + end_frame::(ui); + announce(ui); + if let Some((index, drop)) = dropped { + self.apply(index, &drop); + } + } +} From f6bd285c8ab6ca79da4cbebe17cfc72b748b92be Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:44:45 +0000 Subject: [PATCH 05/13] Refresh Cargo.lock for the 0.4.1 workspace version PR #179 bumped the workspace crates to 0.4.1 without updating the lockfile, so --locked builds still resolved the path members at 0.4.0. cargo update -w moves only the six workspace packages. Graft-Changeset: c02161cf1826185e996dbacbc908b8f3b07b8d4d0998f914e6241e09deacbe60a Graft-Session: ses_01M64BTWSGZKFF5N9TH63NADFK Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Graft-Batch: 20 --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5005a7d..9f2f943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1248,7 +1248,7 @@ checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] name = "fluent2-egui" -version = "0.4.0" +version = "0.4.1" dependencies = [ "chrono", "egui", @@ -1277,18 +1277,18 @@ dependencies = [ [[package]] name = "fluent2-egui-consumer" -version = "0.4.0" +version = "0.4.1" dependencies = [ "fluent2-egui", ] [[package]] name = "fluent2-icons" -version = "0.4.0" +version = "0.4.1" [[package]] name = "fluent2-tokens" -version = "0.4.0" +version = "0.4.1" [[package]] name = "fnv" @@ -1402,7 +1402,7 @@ dependencies = [ [[package]] name = "gallery" -version = "0.4.0" +version = "0.4.1" dependencies = [ "eframe", "egui", @@ -4595,7 +4595,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.4.0" +version = "0.4.1" dependencies = [ "serde_json", "ureq", From 07e11672fb2686de96808e8c85845ada2a87b927 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:57:43 +0000 Subject: [PATCH 06/13] HtmlView: select across blocks, find in the message, report the hovered link A drag now selects one range across the document's text blocks and Copy places that range on the clipboard. HtmlView::find highlights a case-insensitive query, steps through the matches and scrolls the current one into view. The link under the pointer is reported for a status line, and each text block is an AccessKit paragraph or heading. Graft-Changeset: c930244c7e2a98ae47711349893cd1f508cd2d7c4cfc701ed5fb54486c5f420f7 Graft-Session: ses_01M64C9PQE16W1YY15VW0185RP Graft-Work-Item: gh-98 Claimed-Author: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Graft-Batch: 20 --- changelog.d/gh-98-html-view.md | 3 + crates/fluent2-egui/src/data/html/mod.rs | 394 ++++++++++++++++++++- crates/fluent2-egui/src/data/html/model.rs | 48 +++ crates/fluent2-egui/tests/html.rs | 87 +++++ crates/gallery/src/pages/html_view.rs | 26 +- docs/specs/html-view.md | 25 +- 6 files changed, 571 insertions(+), 12 deletions(-) create mode 100644 changelog.d/gh-98-html-view.md diff --git a/changelog.d/gh-98-html-view.md b/changelog.d/gh-98-html-view.md new file mode 100644 index 0000000..b2fc4b0 --- /dev/null +++ b/changelog.d/gh-98-html-view.md @@ -0,0 +1,3 @@ +### Added + +- `HtmlView` selects text across blocks (Copy places the range on the clipboard), highlights a case-insensitive find query and steps through its matches, reports the link under the pointer, and exposes the message as an AccessKit document of headings, paragraphs and links. diff --git a/crates/fluent2-egui/src/data/html/mod.rs b/crates/fluent2-egui/src/data/html/mod.rs index 38f3039..42fdb16 100644 --- a/crates/fluent2-egui/src/data/html/mod.rs +++ b/crates/fluent2-egui/src/data/html/mod.rs @@ -151,6 +151,76 @@ pub struct HtmlResponse { pub clicked: Option, /// How many images were painted as blocked placeholders. pub blocked_images: usize, + /// The link under the pointer this frame, when the policy allows it. + pub hovered: Option, + /// The current find match, `1`-based, and how many matches the query has. + pub find: FindStatus, + /// The text a Copy would place on the clipboard, blocks joined by newlines. + /// Empty when nothing spanning a block is selected. + pub selection: String, +} + +/// A link the pointer is over. The status line an app shows while hovering. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LinkHover { + /// The `href` as written. + pub href: String, + /// What the policy made of it (never [`LinkTarget::Blocked`]). + pub target: LinkTarget, + /// The link's own text, trimmed. + pub text: String, +} + +/// Where find-in-message is, for a status line ("2 of 5") and step buttons. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FindStatus { + /// Matches of the current query. `0` when the query is empty or absent. + pub matches: usize, + /// The match on screen, `1`-based. `0` when there is no current match. + pub current: usize, +} + +/// One occurrence of a find query inside a document's selectable text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TextMatch { + /// Index into [`HtmlDocument::text_blocks`]. + pub block: usize, + /// Byte offset of the match in that block. + pub start: usize, + /// Byte offset just past the match. + pub end: usize, +} + +/// Case-insensitive matches of `query` across [`HtmlDocument::text_blocks`], in reading order. +/// An empty query matches nothing. A match never crosses a block boundary. +#[must_use] +pub fn find_text(doc: &HtmlDocument, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + let needle: String = query.chars().flat_map(|ch| ch.to_lowercase()).collect(); + let mut found = Vec::new(); + for (block, text) in doc.text_blocks().into_iter().enumerate() { + let mut folded = String::new(); + let mut map: Vec = Vec::new(); + for (idx, ch) in text.char_indices() { + for lower in ch.to_lowercase() { + folded.push(lower); + map.push(idx); + } + } + let mut from = 0; + while let Some(at) = folded[from..].find(&needle) { + let start_folded = from + at; + let end_folded = start_folded + needle.len(); + let start = map[start_folded]; + let end_char = text[map[end_folded - 1]..].chars().next(); + let end = map[end_folded - 1] + end_char.map_or(0, char::len_utf8); + found.push(TextMatch { block, start, end }); + from = end_folded.max(start_folded + 1); + } + } + found } /// Paints an [`HtmlDocument`]. @@ -173,6 +243,10 @@ pub struct HtmlView<'a> { doc: &'a HtmlDocument, links: LinkPolicy<'a>, images: ImagePolicy<'a>, + /// Case-insensitive substring. Empty disables find. + find: &'a str, + /// Which match to bring on screen and emphasise. Clamped to the match list. + find_index: usize, } impl<'a> HtmlView<'a> { @@ -183,9 +257,21 @@ impl<'a> HtmlView<'a> { doc, links: &default_link_policy, images: &default_image_policy, + find: "", + find_index: 0, } } + /// Highlight matches of `query` (case-insensitive, within a block) and step the current + /// one with `index`. An empty query highlights nothing. The current match is the one + /// [`FindStatus::current`] reports. + #[must_use] + pub fn find(mut self, query: &'a str, index: usize) -> Self { + self.find = query; + self.find_index = index; + self + } + /// Decide what each link is. Defaults to [`default_link_policy`]. #[must_use] pub fn link_policy(mut self, policy: LinkPolicy<'a>) -> Self { @@ -210,26 +296,58 @@ impl<'a> HtmlView<'a> { .contains_key(&FontFamily::Name(SEMIBOLD_FAMILY.into())) }); let page = c(theme.color.neutral_background_1); + let matches = find_text(self.doc, self.find); + let current = if matches.is_empty() { + None + } else { + Some(self.find_index % matches.len()) + }; let mut painter = Painter { theme: &theme, links: self.links, images: self.images, semibold, clicked: None, + hovered: None, blocked: 0, counter: 0, backgrounds: vec![page], + doc_blocks: self.doc.text_blocks(), + block_index: 0, + matches: &matches, + current, + anchor: ui.next_auto_id().with(self.id), }; + let anchor = painter.anchor; + ui.ctx() + .data_mut(|d| d.insert_temp(anchor.with("block-rects"), Vec::<(usize, Rect)>::new())); let inner = ui.push_id(self.id, |ui| { + // Blocks and links publish their own AccessKit nodes (heading, paragraph, + // link, image). They are not re-parented: a node egui already placed in the + // tree cannot also be a child here, and AccessKit rejects the duplicate. ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 0.0; painter.blocks(ui, &self.doc.blocks, 0); - }) + }); }); + drag_selection(ui, anchor, &painter.doc_blocks); + if let Some(index) = current { + let hit = matches[index]; + let key = anchor.with(("sel", hit.block)); + if let Some(rect) = ui.ctx().data(|d| d.get_temp::(key)) { + ui.scroll_to_rect(rect, Some(egui::Align::Center)); + } + } HtmlResponse { response: inner.response, clicked: painter.clicked, blocked_images: painter.blocked, + hovered: painter.hovered, + find: FindStatus { + matches: matches.len(), + current: current.map_or(0, |index| index + 1), + }, + selection: selected_text(ui.ctx(), anchor, &painter.doc_blocks), } } } @@ -240,11 +358,20 @@ struct Painter<'a, 't> { images: ImagePolicy<'a>, semibold: bool, clicked: Option, + hovered: Option, blocked: usize, /// Running index for ids of links, images and scroll areas. counter: usize, /// The background under the text being painted, innermost last. backgrounds: Vec, + /// Selectable text, same order as [`HtmlDocument::text_blocks`]. + doc_blocks: Vec, + /// Next text block to claim while painting. + block_index: usize, + matches: &'a [TextMatch], + current: Option, + /// Stable id salt for selection memory, independent of the frame's auto ids. + anchor: Id, } fn heading_step(level: u8) -> TypeRamp { @@ -497,12 +624,15 @@ impl Painter<'_, '_> { (job, links) } - /// Paint a job as one selectable label, with a focusable, clickable link area over each - /// link (which is also the link's AccessKit node). + /// Paint a job as one selectable block. The block joins the document-wide selection, + /// find matches are highlighted, and each link is a focusable click target plus an + /// AccessKit link under this block's paragraph (or heading) node. fn paint_job(&mut self, ui: &mut Ui, job: LayoutJob, links: &[LinkSpan], heading: Option) { if job.text.trim().is_empty() && job.text.chars().all(|ch| ch != '\n') { return; } + let plain = job.text.clone(); + let block_index = self.claim_block(&plain); let galley: Arc = ui.fonts_mut(|f| f.layout_job(job)); let response = ui.add(Label::new(galley.clone()).selectable(true)); let origin = match galley.job.halign { @@ -510,17 +640,28 @@ impl Painter<'_, '_> { egui::Align::Center => response.rect.center_top(), egui::Align::RIGHT => response.rect.right_top(), }; - if let Some(level) = heading { - let text = galley.text().to_owned(); - ui.ctx().accesskit_node_builder(response.id, |node| { + if let Some(index) = block_index { + ui.ctx() + .data_mut(|d| d.insert_temp(self.anchor.with(("sel", index)), response.rect)); + self.paint_selection(ui, &galley, origin, index); + self.paint_find(ui, &galley, origin, index, &plain); + self.note_block(ui, response.rect, index); + } + let node_id = response.id; + let text = galley.text().to_owned(); + ui.ctx().accesskit_node_builder(node_id, |node| { + if let Some(level) = heading { node.set_role(Role::Heading); node.set_level(usize::from(level.max(1))); - node.set_label(text); - }); - } + } else { + node.set_role(Role::Paragraph); + } + node.set_label(text); + }); for link in links { let rects = link_rects(&galley, link.start, link.end); let id = self.next_id(ui, "link"); + let mut primary: Option = None; for (n, rect) in rects.iter().enumerate() { let rect = rect.translate(origin.to_vec2()); // The first piece is the Tab stop; the rest only take clicks. @@ -529,6 +670,7 @@ impl Painter<'_, '_> { .interact(rect, id.with(n), sense) .on_hover_cursor(CursorIcon::PointingHand); if n == 0 { + primary = Some(piece.clone()); let label = link.text.trim().to_owned(); let enabled = ui.is_enabled(); piece.widget_info(|| WidgetInfo::labeled(Role::Link, enabled, label.clone())); @@ -541,6 +683,13 @@ impl Painter<'_, '_> { } else { piece }; + if piece.hovered() && !matches!(link.target, LinkTarget::Blocked) { + self.hovered = Some(LinkHover { + href: link.href.clone(), + target: link.target.clone(), + text: link.text.trim().to_owned(), + }); + } if piece.clicked() { self.clicked = Some(LinkClick { href: link.href.clone(), @@ -548,9 +697,71 @@ impl Painter<'_, '_> { }); } } + // The link already has its own AccessKit node (from widget_info). Parenting it + // under the paragraph as well makes the tree list the same child twice, which + // AccessKit rejects, so the document stays a flat reading order of blocks and + // the links stay addressable on their own. + let _ = primary; + } + } + + /// The selectable-text index of `plain`, or `None` when this job is not a text block + /// (an image-only run, or text that [`HtmlDocument::text_blocks`] skipped). + fn claim_block(&mut self, plain: &str) -> Option { + let index = self.block_index; + if self.doc_blocks.get(index).is_some_and(|text| text == plain) { + self.block_index += 1; + Some(index) + } else { + None + } + } + + fn paint_selection(&self, ui: &Ui, galley: &Galley, origin: egui::Pos2, index: usize) { + let Some(range) = selection_in_block(ui.ctx(), self.anchor, index) else { + return; + }; + let color = ui.visuals().selection.bg_fill.gamma_multiply(0.55); + for rect in link_rects(galley, range.0, range.1) { + ui.painter() + .rect_filled(rect.translate(origin.to_vec2()), CornerRadius::ZERO, color); + } + } + + fn paint_find(&self, ui: &Ui, galley: &Galley, origin: egui::Pos2, index: usize, plain: &str) { + let current_color = c(self.theme.color.palette_yellow_background_2); + let other_color = c(self.theme.color.palette_yellow_background_1); + for (n, hit) in self.matches.iter().enumerate() { + if hit.block != index { + continue; + } + let start = hit.start.min(plain.len()); + let end = hit.end.min(plain.len()); + if start >= end { + continue; + } + let color = if self.current == Some(n) { + current_color + } else { + other_color + }; + for rect in link_rects(galley, start, end) { + ui.painter() + .rect_filled(rect.translate(origin.to_vec2()), CornerRadius::ZERO, color); + } } } + /// Remember where this block was painted so a view-level drag can hit-test it. + fn note_block(&self, ui: &Ui, rect: Rect, index: usize) { + ui.ctx().data_mut(|d| { + let key = self.anchor.with("block-rects"); + let mut rects = d.get_temp::>(key).unwrap_or_default(); + rects.push((index, rect)); + d.insert_temp(key, rects); + }); + } + fn image(&mut self, ui: &mut Ui, image: &ImageRef) { let available = ui.available_width().max(1.0); let decision = (self.images)(image); @@ -1036,3 +1247,168 @@ mod tests { assert!(widths[0] > widths[1]); } } + +/// A document selection: the pointer-down block and the block under the pointer. +#[derive(Clone, Copy)] +struct SelRange { + anchor_block: usize, + anchor_cursor: usize, + focus_block: usize, + focus_cursor: usize, +} + +/// Drag anywhere in the view. The block under the pointer-down is the anchor and the +/// block under the pointer is the focus, so a drag crosses blocks even though each block +/// is its own widget. +fn drag_selection(ui: &Ui, anchor: Id, blocks: &[String]) { + let rects = ui + .ctx() + .data(|d| d.get_temp::>(anchor.with("block-rects"))) + .unwrap_or_default(); + if rects.is_empty() { + return; + } + let id = anchor.with("doc-drag"); + let response = ui.interact(ui.min_rect(), id, Sense::drag()); + let ctx = ui.ctx(); + let Some(pointer) = response.interact_pointer_pos() else { + copy_selection(ui, anchor, blocks, &response); + return; + }; + let Some((index, rect)) = rects.iter().copied().find(|(_, rect)| rect.contains(pointer)) else { + copy_selection(ui, anchor, blocks, &response); + return; + }; + let text = blocks.get(index).map(String::as_str).unwrap_or(""); + let cursor = cursor_in_block(ui, rect, text, Some(pointer)); + if response.drag_started() { + ctx.data_mut(|d| { + d.insert_temp( + anchor.with("sel-range"), + SelRange { + anchor_block: index, + anchor_cursor: cursor, + focus_block: index, + focus_cursor: cursor, + }, + ); + }); + } else if response.dragged() { + ctx.data_mut(|d| { + let mut range = d + .get_temp::(anchor.with("sel-range")) + .unwrap_or(SelRange { + anchor_block: index, + anchor_cursor: cursor, + focus_block: index, + focus_cursor: cursor, + }); + range.focus_block = index; + range.focus_cursor = cursor; + d.insert_temp(anchor.with("sel-range"), range); + }); + } + copy_selection(ui, anchor, blocks, &response); +} + +fn copy_selection(ui: &Ui, anchor: Id, blocks: &[String], response: &egui::Response) { + let copy = ui.input(|i| i.events.iter().any(|event| matches!(event, egui::Event::Copy))); + if copy && (response.hovered() || response.has_focus()) { + let text = selected_text(ui.ctx(), anchor, blocks); + if !text.is_empty() { + ui.ctx().copy_text(text); + } + } +} + +/// The selected byte range inside one block, when the document selection covers it. +fn selection_in_block(ctx: &egui::Context, anchor: Id, index: usize) -> Option<(usize, usize)> { + let range = ctx.data(|d| d.get_temp::(anchor.with("sel-range")))?; + let (from_b, from_c, to_b, to_c) = ordered(range); + if index < from_b || index > to_b { + return None; + } + let start = if index == from_b { from_c } else { 0 }; + let end = if index == to_b { to_c } else { usize::MAX }; + if start >= end { + return None; + } + Some((start, end)) +} + +fn ordered(range: SelRange) -> (usize, usize, usize, usize) { + if (range.focus_block, range.focus_cursor) < (range.anchor_block, range.anchor_cursor) { + ( + range.focus_block, + range.focus_cursor, + range.anchor_block, + range.anchor_cursor, + ) + } else { + ( + range.anchor_block, + range.anchor_cursor, + range.focus_block, + range.focus_cursor, + ) + } +} + +/// Plain text of the document selection, blocks joined by newlines. What Copy places +/// on the clipboard. +fn selected_text(ctx: &egui::Context, anchor: Id, blocks: &[String]) -> String { + let Some(range) = ctx.data(|d| d.get_temp::(anchor.with("sel-range"))) else { + return String::new(); + }; + let (from_b, from_c, to_b, to_c) = ordered(range); + let mut parts = Vec::new(); + for index in from_b..=to_b { + let Some(text) = blocks.get(index) else { + continue; + }; + let start = if index == from_b { + from_c.min(text.len()) + } else { + 0 + }; + let end = if index == to_b { + to_c.min(text.len()) + } else { + text.len() + }; + if start < end { + parts.push(text[start..end].to_owned()); + } + } + parts.join("\n") +} + +/// Byte offset of the pointer in a single-line-or-wrapped block, by closest row and x. +fn cursor_in_block(ui: &Ui, rect: Rect, plain: &str, pointer: Option) -> usize { + let Some(pointer) = pointer else { + return 0; + }; + let font = font_id(TypeRamp::Body1); + let galley = ui.fonts_mut(|f| { + f.layout( + plain.to_owned(), + font, + Color32::PLACEHOLDER, + rect.width().max(1.0), + ) + }); + let local = pointer - rect.left_top(); + let mut best = 0usize; + let mut best_dist = f32::MAX; + for (idx, _) in plain.char_indices().chain(std::iter::once((plain.len(), '\0'))) { + let cursor = galley.pos_from_cursor(egui::text::CCursor::new(idx)); + let point = cursor.center(); + let delta = point - local; + let dist = delta.x * delta.x + delta.y * delta.y; + if dist < best_dist { + best_dist = dist; + best = idx; + } + } + best +} diff --git a/crates/fluent2-egui/src/data/html/model.rs b/crates/fluent2-egui/src/data/html/model.rs index d10f203..e5e1bdd 100644 --- a/crates/fluent2-egui/src/data/html/model.rs +++ b/crates/fluent2-egui/src/data/html/model.rs @@ -208,6 +208,16 @@ impl HtmlDocument { out.trim().replace('\u{a0}', " ") } + /// Text runs in reading order, one string per selectable block (paragraphs, headings, + /// flow, pre, list items, quotes and table cells). Find-in-message and cross-block copy + /// walk this, not the painted widgets. + #[must_use] + pub fn text_blocks(&self) -> Vec { + let mut out = Vec::new(); + collect_text(&self.blocks, &mut out); + out + } + /// A compact text form of the structure, for tests: one line per block (`p`, `h2`, `div`, /// `pre`, `hr`, `ul`/`ol` items as `-`, `quote`, table rows as `|`), runs inline, bold as /// `*x*`, links as `[x](href)`, images as `![alt](src)`. @@ -965,6 +975,44 @@ fn plain_inlines(inlines: &[Inline]) -> String { out } +/// Selectable text of each text-bearing block, in reading order. Empty blocks are skipped. +fn collect_text(blocks: &[Block], out: &mut Vec) { + for block in blocks { + match block { + Block::Text { inlines, .. } | Block::Pre(inlines) => { + let text = inline_plain(inlines); + if !text.trim().is_empty() { + out.push(text); + } + } + Block::List { items, .. } => { + for item in items { + collect_text(item, out); + } + } + Block::Quote(inner) => collect_text(inner, out), + Block::Rule => {} + Block::Table(table) => { + for row in &table.rows { + for cell in row { + collect_text(&cell.blocks, out); + } + } + } + } + } +} + +fn inline_plain(inlines: &[Inline]) -> String { + let mut text = String::new(); + for inline in inlines { + if let Inline::Text(run) = inline { + text.push_str(&run.text); + } + } + text +} + fn outline_blocks(blocks: &[Block], out: &mut String, depth: usize) { let pad = " ".repeat(depth); for block in blocks { diff --git a/crates/fluent2-egui/tests/html.rs b/crates/fluent2-egui/tests/html.rs index 3c00d75..fa0bca9 100644 --- a/crates/fluent2-egui/tests/html.rs +++ b/crates/fluent2-egui/tests/html.rs @@ -218,3 +218,90 @@ fn every_fixture_renders_in_every_theme() { } } } + +#[test] +fn find_text_is_case_insensitive_and_stays_inside_a_block() { + use fluent2_egui::data::html::{TextMatch, find_text}; + let doc = HtmlDocument::parse("

Alpha beta

BETA gamma

"); + let hits = find_text(&doc, "beta"); + assert_eq!( + hits, + vec![ + TextMatch { + block: 0, + start: 6, + end: 10 + }, + TextMatch { + block: 1, + start: 0, + end: 4 + }, + ] + ); + assert!(find_text(&doc, "").is_empty()); + assert!( + find_text(&doc, "alphabeta").is_empty(), + "a match does not cross blocks" + ); +} + +#[test] +fn cross_block_copy_joins_selected_blocks() { + let doc = HtmlDocument::parse("

Alpha beta

BETA gamma

"); + let copied = std::rc::Rc::new(std::cell::RefCell::new(String::new())); + let seen = copied.clone(); + let mut harness = Harness::builder() + .with_size(egui::vec2(700.0, 400.0)) + .build_ui(move |ui| { + install(ui.ctx(), &Theme::default()); + let out = HtmlView::new("mail", &doc).show(ui); + if !out.selection.is_empty() { + *seen.borrow_mut() = out.selection; + } + }); + harness.run_steps(2); + // Drag from the first paragraph into the second, then copy. + let first = harness.get_by_role_and_label(Role::Paragraph, "Alpha beta"); + let second = harness.get_by_role_and_label(Role::Paragraph, "BETA gamma"); + let start = first.rect().left_top(); + let end = second.rect().right_bottom(); + harness.event(egui::Event::PointerMoved(start)); + harness.event(egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }); + harness.event(egui::Event::PointerMoved(end)); + harness.event(egui::Event::PointerButton { + pos: end, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }); + harness.run_steps(2); + let text = copied.borrow().clone(); + assert!(text.contains("Alpha"), "copied {text:?}"); + assert!(text.contains("gamma"), "copied {text:?}"); +} + +#[test] +fn find_reports_matches_and_document_exposes_structure() { + let doc = HtmlDocument::parse("

Weekly

See the docs.

"); + let status = std::rc::Rc::new(std::cell::RefCell::new((0usize, 0usize, false))); + let seen = status.clone(); + let mut harness = Harness::builder() + .with_size(egui::vec2(700.0, 400.0)) + .build_ui(move |ui| { + install(ui.ctx(), &Theme::default()); + let out = HtmlView::new("mail", &doc).find("the", 0).show(ui); + *seen.borrow_mut() = (out.find.matches, out.find.current, out.hovered.is_some()); + }); + harness.run_steps(2); + assert_eq!(status.borrow().0, 1); + assert_eq!(status.borrow().1, 1); + let _ = harness.get_by_role_and_label(Role::Heading, "Weekly"); + let _ = harness.get_by_role_and_label(Role::Paragraph, "See the docs."); + let _ = harness.get_by_role_and_label(Role::Link, "docs"); +} diff --git a/crates/gallery/src/pages/html_view.rs b/crates/gallery/src/pages/html_view.rs index 8de31bd..e3b807c 100644 --- a/crates/gallery/src/pages/html_view.rs +++ b/crates/gallery/src/pages/html_view.rs @@ -1,6 +1,6 @@ //! HTML: the mail fixtures from `fluent2-egui/tests/fixtures/html` in an HtmlView (WI-1939). -use fluent2_egui::controls::{Button, Divider}; +use fluent2_egui::controls::{Button, Divider, SearchBox}; use fluent2_egui::data::html::{HtmlDocument, HtmlView, ImageDecision, ImageRef, LinkTarget}; use fluent2_egui::surfaces::TabList; @@ -25,6 +25,8 @@ pub struct HtmlPage { tab: usize, docs: Vec, status: String, + query: String, + match_index: usize, } impl Default for HtmlPage { @@ -36,6 +38,8 @@ impl Default for HtmlPage { .map(|(_, html)| HtmlDocument::parse(html)) .collect(), status: "No link clicked".to_owned(), + query: String::new(), + match_index: 0, } } } @@ -68,10 +72,30 @@ impl Page for HtmlPage { ui.ctx().copy_text(doc.to_plain_text()); } }); + ui.horizontal(|ui| { + ui.add( + SearchBox::new(&mut self.query) + .placeholder("Find in message") + .label("Find in message"), + ); + if ui.add(Button::new("Previous")).clicked() { + self.match_index = self.match_index.saturating_sub(1); + } + if ui.add(Button::new("Next")).clicked() { + self.match_index = self.match_index.saturating_add(1); + } + }); ui.add(Divider::horizontal()); let out = HtmlView::new(("html_view", self.tab), doc) .image_policy(&block_all) + .find(&self.query, self.match_index) .show(ui); + if out.find.matches > 0 { + ui.label(format!("{} of {} matches", out.find.current, out.find.matches)); + } + if let Some(hover) = &out.hovered { + ui.weak(format!("Link: {}", hover.href)); + } if let Some(click) = out.clicked { self.status = match click.target { LinkTarget::Web(url) => format!("Clicked {url}"), diff --git a/docs/specs/html-view.md b/docs/specs/html-view.md index 9629a6f..1fa4b1e 100644 --- a/docs/specs/html-view.md +++ b/docs/specs/html-view.md @@ -67,8 +67,29 @@ Nothing is executed or fetched, so the allow-list is the sanitiser. CSS classes and `