From 330df56bec6de8c0b85fe18861d1b53822f4e2e4 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:06:32 +0300 Subject: [PATCH] feat(input): tap rows, hints and tabs --- CHANGELOG.md | 9 +++ README.md | 3 + src/app/command.rs | 15 ++++ src/app/mod.rs | 54 +++++++++++++++ src/overlay/browser.rs | 6 ++ src/overlay/history.rs | 5 ++ src/overlay/home.rs | 16 +++++ src/overlay/osk.rs | 20 ++++++ src/overlay/routes.rs | 27 ++++++++ src/overlay/settings.rs | 5 ++ src/overlay/tabs.rs | 5 ++ src/ui/about.rs | 5 +- src/ui/browser.rs | 23 ++++-- src/ui/history.rs | 13 +++- src/ui/home.rs | 150 +++++++++++++++++++++++++++++++++++++--- src/ui/mod.rs | 44 ++++++++---- src/ui/osk.rs | 25 ++++--- src/ui/prompt.rs | 10 ++- src/ui/receive.rs | 12 +++- src/ui/routes.rs | 25 +++++-- src/ui/settings.rs | 24 ++++++- src/ui/tabs.rs | 10 ++- src/ui/transfer.rs | 16 +++-- 23 files changed, 463 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea1315..2922402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Touch and mouse input**, which the UI had never read: it is painted from a + cursor that only a pad or the keyboard moved, so on a phone there was nothing + to press. A tap now becomes the same `AppCommand` a button emits — on a row it + places the cursor there and confirms, so tapping a device sends to it and + tapping a file picks it, and each slot of the footer hint bar *is* the button + it names, which is what makes Start/Select/X/Y reachable without a pad. The + same applies to a desktop mouse, where clicking used to do nothing. ## [0.5.5] - 2026-08-18 ### Changed diff --git a/README.md b/README.md index a2d4910..bbcf83f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ cargo test ## Controls +Touch works too, where there is a touchscreen: tap a row to act on it, or tap a +hint along the bottom to press the button it names. + | Pad | Keyboard | Action | |--------------|------------|-----------------------------------------------| | D-pad / stick| Arrows | Navigate · left/right switch tabs | diff --git a/src/app/command.rs b/src/app/command.rs index ffb9ad2..2540cc6 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -1,5 +1,10 @@ +use crate::overlay::tabs::Tab; + /// Everything input can ask the app to do. Input handlers emit these; the /// router in `App::execute_command` interprets them against the current focus. +/// +/// The `Pick*` three are the exception: a tap names what it landed on, so they +/// carry an absolute target and are routed by it rather than by focus. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum AppCommand { Nav(Direction), @@ -17,6 +22,16 @@ pub enum AppCommand { /// file in the folder in the browser. Alt, Shutdown, + /// Put the showing list's cursor on this row. A tap emits it with a + /// [`Self::Confirm`] behind it, which is what makes a tap act. + PickRow(usize), + /// Put the on-screen keyboard's cursor on this key. + PickKey { + row: usize, + col: usize, + }, + /// Switch to this tab. + PickTab(Tab), } #[derive(Copy, Clone, Debug, PartialEq, Eq)] diff --git a/src/app/mod.rs b/src/app/mod.rs index c4bf907..05c3ae1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -101,6 +101,9 @@ impl App { ); let mut commands: Vec = Vec::new(); while self.running { + // Taps land during the render pass, so they precede this frame's + // button presses. + commands.extend(self.ui.take_taps()); self.event_handler.wait(&mut self.ui, &mut commands); for command in commands.drain(..) { self.execute_command(command); @@ -160,6 +163,20 @@ impl App { /// Interpret a command against the current focus — the single place where /// "what does A do right now" is decided. fn execute_command(&mut self, command: AppCommand) { + // A tap names its target, so it routes by that rather than by focus — + // and is dropped if the focus moved on since the frame that drew it. + match command { + AppCommand::PickRow(index) => return self.pick_row(index), + AppCommand::PickKey { row, col } => { + if self.focus() == Focus::Osk { + self.ui.osk.set_cursor(row, col); + } + return; + } + AppCommand::PickTab(tab) => return self.pick_tab(tab), + _ => {} + } + match (self.focus(), command) { (_, AppCommand::Shutdown) => self.running = false, @@ -312,6 +329,43 @@ impl App { Focus::Tabs, AppCommand::Start | AppCommand::Back | AppCommand::TogglePin | AppCommand::Alt, ) => {} + (_, AppCommand::PickRow(_) | AppCommand::PickKey { .. } | AppCommand::PickTab(_)) => { + unreachable!("taps are routed by their target above, before the focus match") + } + } + } + + /// A tapped row: move the showing list's cursor to it. The renderer's + /// trailing `Confirm` is what acts, so a tap equals walking there and A. + fn pick_row(&mut self, index: usize) { + match self.focus() { + Focus::Browser => self.ui.browser.set_cursor(index), + Focus::Routes => { + let (routes, auto) = self.route_counts(); + self.ui.routes.set_cursor(index, routes, auto); + } + Focus::Tabs => match self.ui.tabs.active() { + Tab::Send => self.ui.home.set_cursor(index, self.ui.peer_count), + Tab::History => self.ui.history.set_cursor(index, self.ui.history_count), + Tab::Settings => self.ui.settings.set_cursor(index), + Tab::Receive => {} + }, + Focus::Osk | Focus::Prompt | Focus::Transfer | Focus::About => {} + } + } + + /// A tapped tab, which is where L1/R1 would have landed. + fn pick_tab(&mut self, tab: Tab) { + if self.focus() != Focus::Tabs || self.ui.tabs.active() == tab { + return; + } + let leaving_settings = self.ui.tabs.active() == Tab::Settings; + self.ui.tabs.set_active(tab); + if leaving_settings { + self.leave_settings(); + } + if tab == Tab::Settings { + self.refresh_auto_route_count(); } } diff --git a/src/overlay/browser.rs b/src/overlay/browser.rs index 251039e..8d0b18f 100644 --- a/src/overlay/browser.rs +++ b/src/overlay/browser.rs @@ -177,6 +177,12 @@ impl FileBrowser { (self.cursor.min(self.entries.len() - 1) as i32 + delta).clamp(0, max) as usize; } + /// Straight to `index`, for a tapped row. Clamped: the listing can be + /// rebuilt between the tap and its handling. + pub fn set_cursor(&mut self, index: usize) { + self.cursor = index.min(self.entries.len().saturating_sub(1)); + } + /// A on the cursor row: enter a directory, or toggle a file's selection. /// Returns an error message for the toast when the directory is unreadable. pub fn activate(&mut self) -> Result<(), String> { diff --git a/src/overlay/history.rs b/src/overlay/history.rs index bedb820..9c80174 100644 --- a/src/overlay/history.rs +++ b/src/overlay/history.rs @@ -24,4 +24,9 @@ impl HistoryView { let cur = self.cursor.min(len - 1) as i32; self.cursor = (cur + delta).clamp(0, len as i32 - 1) as usize; } + + /// Straight to `index`, for a tapped row. + pub fn set_cursor(&mut self, index: usize, len: usize) { + self.cursor = index.min(len.saturating_sub(1)); + } } diff --git a/src/overlay/home.rs b/src/overlay/home.rs index e6b1db8..cf67fc1 100644 --- a/src/overlay/home.rs +++ b/src/overlay/home.rs @@ -24,6 +24,12 @@ impl Home { let cur = self.cursor.min(len - 1) as i32; self.cursor = (cur + delta).clamp(0, len as i32 - 1) as usize; } + + /// Straight to `index`, for a tapped row. Clamped: the list is snapshotted + /// per frame, so a peer can expire between the tap and its handling. + pub fn set_cursor(&mut self, index: usize, len: usize) { + self.cursor = index.min(len.saturating_sub(1)); + } } #[cfg(test)] @@ -41,4 +47,14 @@ mod tests { home.move_cursor(-10, 3); assert_eq!(home.cursor(3), Some(0)); } + + #[test] + fn a_tapped_row_clamps_to_the_list_it_was_drawn_from() { + let mut home = Home::new(); + home.set_cursor(2, 3); + assert_eq!(home.cursor(3), Some(2)); + // The row was tapped, then its peer expired. + home.set_cursor(2, 1); + assert_eq!(home.cursor(1), Some(0)); + } } diff --git a/src/overlay/osk.rs b/src/overlay/osk.rs index 99e8d1c..3790672 100644 --- a/src/overlay/osk.rs +++ b/src/overlay/osk.rs @@ -126,6 +126,16 @@ impl Osk { } } + /// Straight to a key, for a tapped one. Ignored if the layer changed under + /// the tap and the key is gone. + pub fn set_cursor(&mut self, row: usize, col: usize) { + let rows = self.rows(); + if rows.get(row).is_some_and(|r| col < r.len()) { + self.row = row; + self.col = col; + } + } + /// A: press the key under the cursor. pub fn press(&mut self) -> Option { let rows = self.rows(); @@ -235,4 +245,14 @@ mod tests { Some(OskEvent::Committed(OskTarget::Alias, _)) )); } + + #[test] + fn a_tapped_key_takes_the_cursor_and_an_off_grid_one_is_ignored() { + let mut osk = Osk::new(); + osk.open(OskTarget::Alias, ""); + osk.set_cursor(1, 2); + assert_eq!((osk.row, osk.col), (1, 2)); + osk.set_cursor(9, 9); + assert_eq!((osk.row, osk.col), (1, 2), "off the grid, so unmoved"); + } } diff --git a/src/overlay/routes.rs b/src/overlay/routes.rs index 509d63f..e22a125 100644 --- a/src/overlay/routes.rs +++ b/src/overlay/routes.rs @@ -83,6 +83,22 @@ impl RoutesView { self.cursor = (current + delta).clamp(0, last) as usize; } + /// Straight to `index`, for a tapped row. + pub fn set_cursor(&mut self, index: usize, routes: usize, auto: usize) { + self.cursor = index.min(Self::last(routes, auto)); + } + + /// The flat position a [`RouteCursor`] sits at — the inverse of + /// [`Self::cursor`], so a tapped row can name itself without the renderer + /// knowing where the add row falls. + pub fn flat_index(cursor: RouteCursor, routes: usize) -> usize { + match cursor { + RouteCursor::Route(i) => i, + RouteCursor::Add => routes, + RouteCursor::Auto(i) => routes + ADD_ROWS + i, + } + } + fn last(routes: usize, auto: usize) -> usize { routes + ADD_ROWS + auto - 1 } @@ -150,4 +166,15 @@ mod tests { let v = view(&[]); assert!(v.auto_rows(&configured(&[("gba", "gba")])).is_empty()); } + + #[test] + fn flat_index_inverts_the_cursor_mapping() { + let (routes, auto) = (2, 3); + let mut view = view(&[("gba", "gba"), ("sfc", "snes"), ("gb", "gb")]); + for i in 0..routes + 1 + auto { + view.set_cursor(i, routes, auto); + let cursor = view.cursor(routes, auto); + assert_eq!(RoutesView::flat_index(cursor, routes), i, "row {i}"); + } + } } diff --git a/src/overlay/settings.rs b/src/overlay/settings.rs index 2342962..1ef4d37 100644 --- a/src/overlay/settings.rs +++ b/src/overlay/settings.rs @@ -58,4 +58,9 @@ impl Settings { let count = ROW_COUNT as i32; self.cursor = (self.cursor as i32 + delta).rem_euclid(count) as usize; } + + /// Straight to `index`, for a tapped row. + pub fn set_cursor(&mut self, index: usize) { + self.cursor = index.min(ROW_COUNT - 1); + } } diff --git a/src/overlay/tabs.rs b/src/overlay/tabs.rs index 25fcf05..90769d2 100644 --- a/src/overlay/tabs.rs +++ b/src/overlay/tabs.rs @@ -30,6 +30,11 @@ impl Tabs { self.active } + /// A tapped tab in the bar. + pub fn set_active(&mut self, tab: Tab) { + self.active = tab; + } + /// L1/R1: step to the previous/next tab, wrapping around. pub fn cycle(&mut self, delta: i32) { let idx = ORDER.iter().position(|t| *t == self.active).unwrap_or(0) as i32; diff --git a/src/ui/about.rs b/src/ui/about.rs index 265c17e..e67821c 100644 --- a/src/ui/about.rs +++ b/src/ui/about.rs @@ -4,9 +4,10 @@ //! plus the `RETSEND_*` vars stamped by `build.rs`), so nothing is threaded in. use super::{theme, wordmark}; +use crate::app::AppCommand; use egui_sdl2::egui; -pub fn render(root: &mut egui::Ui) { +pub fn render(root: &mut egui::Ui, taps: &mut Vec) { // Otherwise-decorative header so About carries the shared top panel like // every other base screen; see [`super::TOP_PANEL_ID`] for why every screen // must draw one top and one bottom panel under the same ids. @@ -22,7 +23,7 @@ pub fn render(root: &mut egui::Ui) { egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); - super::home::hint_bar(ui, &[("B", "Back")]); + super::home::hint_bar(ui, &[("B", "Back", Some(AppCommand::Back))], taps); ui.add_space(4.0); }); diff --git a/src/ui/browser.rs b/src/ui/browser.rs index 2aa21b4..e6d0958 100644 --- a/src/ui/browser.rs +++ b/src/ui/browser.rs @@ -2,6 +2,7 @@ //! selection checkboxes, and a footer with the running selection total. use super::{theme, truncate_middle}; +use crate::app::AppCommand; use crate::overlay::browser::{BrowserMode, DirPurpose, FileBrowser}; use egui_sdl2::egui; @@ -20,6 +21,7 @@ pub fn render( browser: &FileBrowser, target_alias: &str, deadline_secs: Option, + taps: &mut Vec, ) { let picking_dir = browser.mode == BrowserMode::PickDir; let for_incoming = picking_dir && browser.dir_purpose == DirPurpose::Incoming; @@ -74,9 +76,12 @@ pub fn render( (true, false) => "Choose here", (false, _) => "Send", }; - let mut hints: Vec<(&str, &str)> = vec![("Select", "Roots"), ("Start", start_hint)]; + let mut hints: Vec = vec![ + ("Select", "Roots", Some(AppCommand::ReAnnounce)), + ("Start", start_hint, Some(AppCommand::Start)), + ]; if !picking_dir { - hints.push(("X", "All")); + hints.push(("X", "All", Some(AppCommand::Alt))); } hints.push(( "Y", @@ -85,10 +90,11 @@ pub fn render( } else { "Pin" }, + Some(AppCommand::TogglePin), )); - hints.push(("B", "Up")); - hints.push(("A", if picking_dir { "Open" } else { "Select/Open" })); - super::home::hint_bar(ui, &hints); + hints.push(("B", "Up", Some(AppCommand::Back))); + hints.push(("A", if picking_dir { "Open" } else { "Select/Open" }, None)); + super::home::hint_bar(ui, &hints, taps); }); ui.add_space(4.0); }); @@ -126,9 +132,16 @@ pub fn render( } let first = (viewport.min.y / step).max(0.0) as usize; let last = ((viewport.max.y / step).ceil() as usize + 1).min(total); + // Hit-tested, not sensed: virtualized rows are painted from rects, + // not allocated. + let tap = super::home::tap_pos(ui); for i in first..last { let entry = &browser.entries[i]; let rect = row_rect(i); + if tap.is_some_and(|pos| rect.contains(pos)) { + taps.push(AppCommand::PickRow(i)); + taps.push(AppCommand::Confirm); + } if browser.cursor == i { ui.painter().rect( rect, diff --git a/src/ui/history.rs b/src/ui/history.rs index 3da1397..c5a54ce 100644 --- a/src/ui/history.rs +++ b/src/ui/history.rs @@ -4,6 +4,7 @@ //! just scrolls. use super::{fmt_bytes, theme, truncate_middle, PATH_CHARS}; +use crate::app::AppCommand; use crate::transfer::history::{Direction, HistoryEntry, Outcome}; use egui_sdl2::egui; @@ -52,10 +53,10 @@ pub fn row(e: &HistoryEntry, now: u64) -> HistoryRow { } } -pub fn render(root: &mut egui::Ui, data: &HistoryData) { +pub fn render(root: &mut egui::Ui, data: &HistoryData, taps: &mut Vec) { egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); - super::home::hint_bar(ui, &[("← →", "Tabs")]); + super::home::hint_bar(ui, &[("← →", "Tabs", None)], taps); ui.add_space(4.0); }); @@ -100,11 +101,17 @@ pub fn render(root: &mut egui::Ui, data: &HistoryData) { let first = tops .partition_point(|&t| t <= viewport.min.y) .saturating_sub(1); + // Read-only rows, so a tap only carries the cursor there. + let tap = super::home::tap_pos(ui); for (i, row) in data.rows.iter().enumerate().skip(first) { if tops[i] > viewport.max.y { break; } - history_row(ui, row, row_rect(i), data.cursor == Some(i)); + let rect = row_rect(i); + if tap.is_some_and(|pos| rect.contains(pos)) { + taps.push(AppCommand::PickRow(i)); + } + history_row(ui, row, rect, data.cursor == Some(i)); } }); }); diff --git a/src/ui/home.rs b/src/ui/home.rs index e428e3d..b180cfd 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -3,8 +3,16 @@ //! Receive tab. use super::{theme, wordmark}; +use crate::app::AppCommand; use egui_sdl2::egui; +/// A footer hint: the button, what it does, and the command a tap on its slot +/// stands for — `None` for the ones naming no single command. +pub type Hint<'a> = (&'a str, &'a str, Option); + +/// A hint slot is one text row tall; taps get a little more to aim at. +const HINT_TAP_PAD: f32 = 6.0; + /// A display-ready radar row. `AppUi::update` builds these from the peer /// registry, keeping this renderer decoupled from the net layer. pub struct PeerRow { @@ -24,17 +32,18 @@ pub struct HomeData { pub cursor: Option, } -pub fn render(root: &mut egui::Ui, data: &HomeData) { +pub fn render(root: &mut egui::Ui, data: &HomeData, taps: &mut Vec) { egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); hint_bar( ui, &[ - ("← →", "Tabs"), - ("Select", "Refresh"), - ("X", "Add IP"), - ("A", "Choose files"), + ("← →", "Tabs", None), + ("Select", "Refresh", Some(AppCommand::ReAnnounce)), + ("X", "Add IP", Some(AppCommand::Alt)), + ("A", "Choose files", Some(AppCommand::Confirm)), ], + taps, ); ui.add_space(4.0); }); @@ -72,6 +81,10 @@ pub fn render(root: &mut egui::Ui, data: &HomeData) { if selected { row.scroll_to_me(None); } + if row.clicked() { + taps.push(AppCommand::PickRow(i)); + taps.push(AppCommand::Confirm); + } } }); }); @@ -79,7 +92,7 @@ pub fn render(root: &mut egui::Ui, data: &HomeData) { fn peer_row(ui: &mut egui::Ui, peer: &PeerRow, selected: bool) -> egui::Response { let desired = egui::vec2(ui.available_width(), theme::ROW_HEIGHT); - let (rect, response) = ui.allocate_exact_size(desired, egui::Sense::hover()); + let (rect, response) = ui.allocate_exact_size(desired, egui::Sense::click()); if selected { ui.painter().rect( rect, @@ -120,28 +133,57 @@ fn peer_row(ui: &mut egui::Ui, peer: &PeerRow, selected: bool) -> egui::Response /// `[Btn] Action` hints spread evenly across the width — each hint owns an /// equal slot and sits centered in it, matching the tab bar. Shared by every /// screen's footer. -pub fn hint_bar(ui: &mut egui::Ui, hints: &[(&str, &str)]) { +pub fn hint_bar(ui: &mut egui::Ui, hints: &[Hint], taps: &mut Vec) { if hints.is_empty() { return; } // Painted directly over one reserved row — no nested layout, no per-slot // interactive widgets. The hint count differs per tab, and any widget id - // shifting between egui's passes paints a red line at the panel edge. + // shifting between egui's passes paints a red line at the panel edge. Taps + // are hit-tested against the slots for the same reason. let galleys: Vec<_> = hints .iter() - .map(|(button, action)| hint_galley(ui, button, action)) + .map(|(button, action, _)| hint_galley(ui, button, action)) .collect(); let row_h = galleys.iter().map(|g| g.size().y).fold(0.0_f32, f32::max); let full_w = ui.available_width(); let (_, rect) = ui.allocate_space(egui::vec2(full_w, row_h)); let slot_w = full_w / hints.len() as f32; + let tap = tap_pos(ui); for (i, galley) in galleys.into_iter().enumerate() { let center = egui::pos2(rect.left() + slot_w * (i as f32 + 0.5), rect.center().y); + // A hint names a button, so its slot *is* that button — the only way to + // reach Start/Select/X/Y on a device with no pad. + if let Some(command) = hints[i].2 { + let slot = egui::Rect::from_center_size(center, egui::vec2(slot_w, row_h)) + .expand2(egui::vec2(0.0, HINT_TAP_PAD)); + if tap.is_some_and(|pos| slot.contains(pos)) { + taps.push(command); + } + } ui.painter() .galley(center - galley.size() / 2.0, galley, theme::DIM); } } +/// Where a tap landed, for the bars and virtualized lists that paint themselves +/// instead of allocating widgets. `interact_pos` survives the `PointerGone` a +/// lifted finger sends, so it reads touches too. +/// +/// The filters are what a sensed widget gets from egui for free: a rect can run +/// past its clip rect (virtualized rows, padded hint slots), and a layer above +/// owns the tap (the incoming-request modal). +pub fn tap_pos(ui: &egui::Ui) -> Option { + ui.input(|i| { + i.pointer + .primary_clicked() + .then(|| i.pointer.interact_pos()) + .flatten() + }) + .filter(|pos| ui.clip_rect().contains(*pos)) + .filter(|pos| ui.ctx().layer_id_at(*pos) == Some(ui.layer_id())) +} + /// A `button` (accent) + `action` (dim) hint laid out as one galley so it can /// be centered in its slot as a unit. fn hint_galley(ui: &egui::Ui, button: &str, action: &str) -> std::sync::Arc { @@ -167,3 +209,93 @@ fn hint_galley(ui: &egui::Ui, button: &str, action: &str) -> std::sync::Arc Vec { + let ctx = egui::Context::default(); + let mut taps = Vec::new(); + for frame in 0..2 { + taps.clear(); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN)), + events: if frame == 0 { Vec::new() } else { click(pos) }, + ..Default::default() + }; + ctx.begin_pass(input); + let mut root = egui::Ui::new( + ctx.clone(), + egui::Id::new("root_ui"), + egui::UiBuilder::new().max_rect(ctx.content_rect()), + ); + render(&mut root, data, &mut taps); + // Nothing paints the frame here, and an unapplied delta panics on drop. + ctx.end_pass().textures_delta.clear(); + } + taps + } + + fn click(pos: egui::Pos2) -> Vec { + let button = |pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + vec![egui::Event::PointerMoved(pos), button(true), button(false)] + } + + fn data(peers: usize) -> HomeData { + HomeData { + peers: (0..peers) + .map(|i| PeerRow { + alias: format!("peer{i}"), + detail: "192.168.1.2".to_string(), + insecure: false, + }) + .collect(), + cursor: Some(0), + } + } + + #[test] + fn tapping_a_radar_row_picks_it_and_sends() { + let top_row = egui::pos2(100.0, 4.0); + assert_eq!( + tap_at(&data(3), top_row), + vec![AppCommand::PickRow(0), AppCommand::Confirm] + ); + } + + #[test] + fn tapping_past_the_last_row_does_nothing() { + let below = egui::pos2(100.0, SCREEN.y / 2.0); + assert!(tap_at(&data(1), below).is_empty()); + } + + #[test] + fn tapping_a_hint_presses_the_button_it_names() { + // Four slots; "Select · Refresh" is the second, "A · Choose files" the last. + let bar_y = SCREEN.y - 12.0; + assert_eq!( + tap_at(&data(1), egui::pos2(SCREEN.x * 0.375, bar_y)), + vec![AppCommand::ReAnnounce] + ); + assert_eq!( + tap_at(&data(1), egui::pos2(SCREEN.x * 0.875, bar_y)), + vec![AppCommand::Confirm] + ); + } + + #[test] + fn the_tabs_hint_names_no_single_button_so_it_stays_inert() { + let first_slot = egui::pos2(SCREEN.x * 0.125, SCREEN.y - 12.0); + assert!(tap_at(&data(1), first_slot).is_empty()); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 52650e9..d43e2cf 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,6 +15,7 @@ pub mod theme; mod transfer; mod wordmark; +use crate::app::AppCommand; use crate::config::AppConfig; use crate::net::server::DECISION_TIMEOUT; use crate::net::NetService; @@ -119,6 +120,9 @@ pub struct AppUi { /// History entry count as of the last frame that showed the History tab — /// clamps the history cursor. pub history_count: usize, + /// Commands from taps on the frame just drawn, for `App` to run next pass: + /// the screens are painted from a cursor, so a tap has to become a command. + taps: Vec, /// Throttled IP/SSID for the Receive screen's diagnostic line. net_status: NetStatusCache, } @@ -147,6 +151,7 @@ impl AppUi { toasts: Toasts::new(), peer_count: 0, history_count: 0, + taps: Vec::new(), net_status: NetStatusCache::new(), }) } @@ -161,6 +166,11 @@ impl AppUi { self.repaint_delay.take() } + /// What the last frame's taps amount to, for the command router. + pub fn take_taps(&mut self) -> Vec { + std::mem::take(&mut self.taps) + } + /// Build the frame. Reads shared net state (brief locks) before entering /// the egui closure. pub fn update(&mut self, net: &NetService, config: &AppConfig, history: &History) { @@ -181,6 +191,8 @@ impl AppUi { let toasts: Vec = self.toasts.live().map(str::to_string).collect(); let actual_port = net.http_port(); + // Local, not `self.taps`: the closure already borrows the state it draws. + let mut taps: Vec = Vec::new(); self.egui.run(|ctx| { // egui 0.34 panels are shown inside an explicit root Ui spanning // the window (retsurf's pattern; top-level `show` is deprecated). @@ -196,32 +208,33 @@ impl AppUi { &self.browser, &self.browser.target_alias, deadline_secs, + &mut taps, ), - Screen::Routes(data) => routes::render(&mut root, data), - Screen::About => about::render(&mut root), - Screen::Transfer(data) => transfer::render(&mut root, data), + Screen::Routes(data) => routes::render(&mut root, data, &mut taps), + Screen::About => about::render(&mut root, &mut taps), + Screen::Transfer(data) => transfer::render(&mut root, data, &mut taps), Screen::Send(data) => { - tabs::render_bar(&mut root, active_tab); - home::render(&mut root, data); + tabs::render_bar(&mut root, active_tab, &mut taps); + home::render(&mut root, data, &mut taps); } Screen::Receive(data) => { - tabs::render_bar(&mut root, active_tab); - receive::render(&mut root, data); + tabs::render_bar(&mut root, active_tab, &mut taps); + receive::render(&mut root, data, &mut taps); } Screen::History(data) => { - tabs::render_bar(&mut root, active_tab); - history::render(&mut root, data); + tabs::render_bar(&mut root, active_tab, &mut taps); + history::render(&mut root, data, &mut taps); } Screen::Settings => { - tabs::render_bar(&mut root, active_tab); - settings::render(&mut root, settings_state, config, actual_port); + tabs::render_bar(&mut root, active_tab, &mut taps); + settings::render(&mut root, settings_state, config, actual_port, &mut taps); } } if let Some(p) = prompt_data.as_ref().filter(|_| !picking_incoming) { - prompt::render(ctx, p); + prompt::render(ctx, p, &mut taps); } if self.osk.active { - osk::render(ctx, &self.osk); + osk::render(ctx, &self.osk, &mut taps); } render_toasts(ctx, &toasts); }); @@ -236,6 +249,11 @@ impl AppUi { if prompt_data.is_some() { delay = delay.min(PROMPT_REFRESH); } + // Acted on next pass, so the loop must not block on an event first. + if !taps.is_empty() { + delay = Duration::ZERO; + } + self.taps = taps; self.repaint_delay = Some(delay); } diff --git a/src/ui/osk.rs b/src/ui/osk.rs index f26f7f5..1f96143 100644 --- a/src/ui/osk.rs +++ b/src/ui/osk.rs @@ -2,10 +2,11 @@ //! anchored to the bottom of the screen. use super::theme; +use crate::app::AppCommand; use crate::overlay::osk::{Key, Osk}; use egui_sdl2::egui; -pub fn render(ctx: &egui::Context, osk: &Osk) { +pub fn render(ctx: &egui::Context, osk: &Osk, taps: &mut Vec) { let screen = ctx.content_rect(); egui::Area::new(egui::Id::new("osk")) .anchor(egui::Align2::CENTER_BOTTOM, egui::vec2(0.0, -8.0)) @@ -39,10 +40,17 @@ pub fn render(ctx: &egui::Context, osk: &Osk) { let width = ui.available_width(); let key_w = (width - gap * (row.len() as f32 - 1.0)) / row.len() as f32; for (col_index, key) in row.iter().enumerate() { - let (rect, _) = ui.allocate_exact_size( + let (rect, response) = ui.allocate_exact_size( egui::vec2(key_w, key_h), - egui::Sense::hover(), + egui::Sense::click(), ); + if response.clicked() { + taps.push(AppCommand::PickKey { + row: row_index, + col: col_index, + }); + taps.push(AppCommand::Confirm); + } let selected = osk.row == row_index && osk.col == col_index; let (fill, stroke) = if selected { ( @@ -89,12 +97,13 @@ pub fn render(ctx: &egui::Context, osk: &Osk) { super::home::hint_bar( ui, &[ - ("Select", "Layer"), - ("Start", "OK"), - ("X", "Erase"), - ("B", "Back"), - ("A", "Type"), + ("Select", "Layer", Some(AppCommand::ReAnnounce)), + ("Start", "OK", Some(AppCommand::Start)), + ("X", "Erase", Some(AppCommand::Alt)), + ("B", "Back", Some(AppCommand::Back)), + ("A", "Type", None), ], + taps, ); }); }); diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index ffe02fe..ec3d856 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -3,6 +3,7 @@ //! underneath whenever a prepare-upload is parked. use super::theme; +use crate::app::AppCommand; use egui_sdl2::egui; /// Files shown by name before collapsing into "…and N more". @@ -26,7 +27,7 @@ pub struct PromptData { pub remaining: f32, } -pub fn render(ctx: &egui::Context, data: &PromptData) { +pub fn render(ctx: &egui::Context, data: &PromptData, taps: &mut Vec) { // Dim the screen underneath so the modal reads as blocking. let screen = ctx.content_rect(); egui::Area::new(egui::Id::new("prompt_backdrop")) @@ -122,7 +123,12 @@ pub fn render(ctx: &egui::Context, data: &PromptData) { ui.add_space(10.0); super::home::hint_bar( ui, - &[("B", "Decline"), ("X", "Save to…"), ("A", "Accept")], + &[ + ("B", "Decline", Some(AppCommand::Back)), + ("X", "Save to…", Some(AppCommand::Alt)), + ("A", "Accept", Some(AppCommand::Confirm)), + ], + taps, ); }); }); diff --git a/src/ui/receive.rs b/src/ui/receive.rs index 5d4e5e5..5a3644b 100644 --- a/src/ui/receive.rs +++ b/src/ui/receive.rs @@ -4,6 +4,7 @@ //! Incoming requests still arrive as the Prompt modal on top of this. use super::{theme, wordmark}; +use crate::app::AppCommand; use egui_sdl2::egui; /// Everything the Receive renderer needs, snapshotted by `AppUi::update`. @@ -23,11 +24,18 @@ pub struct ReceiveData { pub quick_save: bool, } -pub fn render(root: &mut egui::Ui, data: &ReceiveData) { +pub fn render(root: &mut egui::Ui, data: &ReceiveData, taps: &mut Vec) { egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); // "Refresh" is the radar's word; here the button re-announces us. - super::home::hint_bar(ui, &[("← →", "Tabs"), ("Select", "Announce")]); + super::home::hint_bar( + ui, + &[ + ("← →", "Tabs", None), + ("Select", "Announce", Some(AppCommand::ReAnnounce)), + ], + taps, + ); ui.add_space(4.0); }); diff --git a/src/ui/routes.rs b/src/ui/routes.rs index 1065df9..51c67a4 100644 --- a/src/ui/routes.rs +++ b/src/ui/routes.rs @@ -4,7 +4,8 @@ //! route removes it; B goes back. use super::theme; -use crate::overlay::routes::RouteCursor; +use crate::app::AppCommand; +use crate::overlay::routes::{RouteCursor, RoutesView}; use egui_sdl2::egui; pub struct RoutesData { @@ -17,7 +18,7 @@ pub struct RoutesData { pub auto_on: bool, } -pub fn render(root: &mut egui::Ui, data: &RoutesData) { +pub fn render(root: &mut egui::Ui, data: &RoutesData, taps: &mut Vec) { egui::Panel::top(super::TOP_PANEL_ID).show(root, |ui| { ui.add_space(6.0); ui.label( @@ -35,28 +36,37 @@ pub fn render(root: &mut egui::Ui, data: &RoutesData) { egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); - let mut hints = vec![("B", "Back")]; + let mut hints: Vec = vec![("B", "Back", Some(AppCommand::Back))]; if let Some(action) = confirm_hint(data.cursor) { - hints.push(("A", action)); + hints.push(("A", action, Some(AppCommand::Confirm))); } - super::home::hint_bar(ui, &hints); + super::home::hint_bar(ui, &hints, taps); ui.add_space(4.0); }); egui::CentralPanel::default().show(root, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { + let routes = data.rows.len(); + let mut tapped = |resp: &egui::Response, cursor: RouteCursor| { + if resp.clicked() { + taps.push(AppCommand::PickRow(RoutesView::flat_index(cursor, routes))); + taps.push(AppCommand::Confirm); + } + }; for (i, (ext, folder)) in data.rows.iter().enumerate() { let selected = data.cursor == RouteCursor::Route(i); let resp = route_row(ui, ext, folder, selected, false); if selected { resp.scroll_to_me(None); } + tapped(&resp, RouteCursor::Route(i)); } let on_add = data.cursor == RouteCursor::Add; let resp = add_row(ui, on_add); if on_add { resp.scroll_to_me(None); } + tapped(&resp, RouteCursor::Add); if data.auto_on { ui.add_space(10.0); ui.label( @@ -70,6 +80,7 @@ pub fn render(root: &mut egui::Ui, data: &RoutesData) { if selected { resp.scroll_to_me(None); } + tapped(&resp, RouteCursor::Auto(i)); } } }); @@ -96,7 +107,7 @@ fn route_row( ) -> egui::Response { let (rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width(), theme::ROW_HEIGHT), - egui::Sense::hover(), + egui::Sense::click(), ); if selected { highlight(ui, rect); @@ -128,7 +139,7 @@ fn route_row( fn add_row(ui: &mut egui::Ui, selected: bool) -> egui::Response { let (rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width(), theme::ROW_HEIGHT), - egui::Sense::hover(), + egui::Sense::click(), ); if selected { highlight(ui, rect); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index f66ff8d..17d90c0 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -2,11 +2,18 @@ //! row's action in the footer. use super::theme; +use crate::app::AppCommand; use crate::config::AppConfig; use crate::overlay::settings::Settings; use egui_sdl2::egui; -pub fn render(root: &mut egui::Ui, state: &Settings, config: &AppConfig, actual_port: u16) { +pub fn render( + root: &mut egui::Ui, + state: &Settings, + config: &AppConfig, + actual_port: u16, + taps: &mut Vec, +) { // Order matches `crate::overlay::settings::ROWS`; third field is the A verb. let rows: [(&str, String, &str); crate::overlay::settings::ROW_COUNT] = [ ("Device name", config.device.alias.clone(), "Edit"), @@ -71,7 +78,14 @@ pub fn render(root: &mut egui::Ui, state: &Settings, config: &AppConfig, actual_ ui.add_space(4.0); // The row's action, where every other screen puts its buttons. let action = rows[state.cursor.min(rows.len() - 1)].2; - super::home::hint_bar(ui, &[("← →", "Tabs"), ("A", action)]); + super::home::hint_bar( + ui, + &[ + ("← →", "Tabs", None), + ("A", action, Some(AppCommand::Confirm)), + ], + taps, + ); ui.add_space(4.0); }); @@ -82,7 +96,11 @@ pub fn render(root: &mut egui::Ui, state: &Settings, config: &AppConfig, actual_ for (i, (name, value, _)) in rows.iter().enumerate() { let selected = state.cursor == i; let desired = egui::vec2(ui.available_width(), theme::ROW_HEIGHT); - let (rect, response) = ui.allocate_exact_size(desired, egui::Sense::hover()); + let (rect, response) = ui.allocate_exact_size(desired, egui::Sense::click()); + if response.clicked() { + taps.push(AppCommand::PickRow(i)); + taps.push(AppCommand::Confirm); + } if selected { response.scroll_to_me(None); ui.painter().rect( diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 665570c..7179cc6 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -4,6 +4,7 @@ //! takeovers (`crate::ui::mod` decides). use super::theme; +use crate::app::AppCommand; use crate::overlay::tabs::Tab; use egui_sdl2::egui; @@ -21,7 +22,7 @@ const TABS: [(Tab, &str, &str); 4] = [ /// Inner padding of a pill (text → pill edge). const PAD: egui::Vec2 = egui::vec2(12.0, 4.0); -pub fn render_bar(root: &mut egui::Ui, active: Tab) { +pub fn render_bar(root: &mut egui::Ui, active: Tab, taps: &mut Vec) { egui::Panel::top(super::TOP_PANEL_ID).show(root, |ui| { ui.add_space(6.0); @@ -50,10 +51,17 @@ pub fn render_bar(root: &mut egui::Ui, active: Tab) { let (_, rect) = ui.allocate_space(egui::vec2(full_w, row_h)); let slot_w = full_w / TABS.len() as f32; + let tap = super::home::tap_pos(ui); for (i, (galley, color, is_active)) in items.into_iter().enumerate() { // Center each pill in its equal slice of the bar. let center = egui::pos2(rect.left() + slot_w * (i as f32 + 0.5), rect.center().y); let pill = egui::Rect::from_center_size(center, galley.size() + PAD * 2.0); + // The whole slice takes the tap, not just the pill — four pills + // leave gaps a finger falls into. + let slot = egui::Rect::from_center_size(center, egui::vec2(slot_w, row_h)); + if tap.is_some_and(|pos| slot.contains(pos)) { + taps.push(AppCommand::PickTab(TABS[i].0)); + } if is_active { ui.painter().rect( pill, diff --git a/src/ui/transfer.rs b/src/ui/transfer.rs index 9990ce2..62a4381 100644 --- a/src/ui/transfer.rs +++ b/src/ui/transfer.rs @@ -2,6 +2,7 @@ //! finish summary, and the two-step cancel confirmation. use super::theme; +use crate::app::AppCommand; use egui_sdl2::egui; pub struct TransferData { @@ -24,7 +25,7 @@ pub struct FileRow { pub frac: f32, } -pub fn render(root: &mut egui::Ui, data: &TransferData) { +pub fn render(root: &mut egui::Ui, data: &TransferData, taps: &mut Vec) { egui::Panel::top(super::TOP_PANEL_ID).show(root, |ui| { ui.add_space(6.0); ui.label( @@ -44,12 +45,19 @@ pub fn render(root: &mut egui::Ui, data: &TransferData) { .size(theme::DETAIL_FONT) .strong(), ); - super::home::hint_bar(ui, &[("B", "Keep going"), ("A", "Yes, cancel")]); + super::home::hint_bar( + ui, + &[ + ("B", "Keep going", Some(AppCommand::Back)), + ("A", "Yes, cancel", Some(AppCommand::Confirm)), + ], + taps, + ); }); } else if data.finished { - super::home::hint_bar(ui, &[("B", "Back")]); + super::home::hint_bar(ui, &[("B", "Back", Some(AppCommand::Back))], taps); } else { - super::home::hint_bar(ui, &[("B", "Cancel")]); + super::home::hint_bar(ui, &[("B", "Cancel", Some(AppCommand::Back))], taps); } ui.add_space(4.0); });