Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 15 additions & 0 deletions src/app/command.rs
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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)]
Expand Down
54 changes: 54 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ impl App {
);
let mut commands: Vec<AppCommand> = 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);
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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();
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/overlay/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
5 changes: 5 additions & 0 deletions src/overlay/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
16 changes: 16 additions & 0 deletions src/overlay/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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));
}
}
20 changes: 20 additions & 0 deletions src/overlay/osk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OskEvent> {
let rows = self.rows();
Expand Down Expand Up @@ -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");
}
}
27 changes: 27 additions & 0 deletions src/overlay/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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}");
}
}
}
5 changes: 5 additions & 0 deletions src/overlay/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
5 changes: 5 additions & 0 deletions src/overlay/tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/ui/about.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppCommand>) {
// 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.
Expand All @@ -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);
});

Expand Down
23 changes: 18 additions & 5 deletions src/ui/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,6 +21,7 @@ pub fn render(
browser: &FileBrowser,
target_alias: &str,
deadline_secs: Option<u32>,
taps: &mut Vec<AppCommand>,
) {
let picking_dir = browser.mode == BrowserMode::PickDir;
let for_incoming = picking_dir && browser.dir_purpose == DirPurpose::Incoming;
Expand Down Expand Up @@ -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<super::home::Hint> = 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",
Expand All @@ -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);
});
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions src/ui/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<AppCommand>) {
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);
});

Expand Down Expand Up @@ -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));
}
});
});
Expand Down
Loading
Loading