From 50f8359f32acd5eb753dc306c57f3d7354d68e60 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:44:30 +0300 Subject: [PATCH 1/3] feat(display): a UI scale fitted to the panel --- CHANGELOG.md | 20 ++ README.md | 7 +- android/README.md | 3 +- .../java/com/retsend/RetsendActivity.java | 5 +- src/app/mod.rs | 20 ++ src/config/display.rs | 14 ++ src/config/mod.rs | 25 ++- src/config/paths.rs | 9 +- src/overlay/settings.rs | 14 +- src/ui/mod.rs | 212 +++++++++++++++++- src/ui/settings.rs | 72 ++++-- 11 files changed, 363 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513ee95..e22db89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **The UI fits itself to the screen it is on.** Every screen is laid out in + points against a 640×480 design, and one zoom factor carries the lot — fonts, + rows, gaps — so a desktop window and a 752×560 Flip panel show the same layout + at the size each has room for, rather than the design at 1:1 pixels and text + that shrinks with the panel. A panel only a little past the design keeps a + whole zoom: fractional zoom lands glyphs between pixels, which reads as uneven + type where there is no subpixel positioning, so the spare pixels become margin + instead. A phone held upright is the same screen turned, so the fit measures + long edge to long edge and the lists get a narrower measure, not smaller rows. +- **`RETSEND_SCALE` now pins the base** the setting is read over, instead of + being the whole answer. Android no longer passes the display density — the fit + lands on the same physical size at any density. + ### Added +- **A "UI scale" setting**, 60% to 160% of what the screen itself asks for, + stepped with ◂ ▸ on its row (L1/R1 still switch tabs, and the hints name both + arrows so a screen with no pad can reach them). Persisted as + `[display] scale`, applied live. + - **A send can be repeated from the History tab**, with A on its row. The log now keeps the source paths of a send and the address it went to; the resend dials the peer under that alias as the radar has it *now*, so a device that diff --git a/README.md b/README.md index cf6273c..16eed4c 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ hint along the bottom to press the button it names. | Pad | Keyboard | Action | |--------------|------------|-----------------------------------------------| -| D-pad / stick| Arrows | Navigate · left/right switch tabs | +| D-pad / stick| Arrows | Navigate · left/right switch tabs, or step a settings value | | A | Enter | Send to device · select file · accept · repeat a send · type | | B | Esc | Back · decline · cancel · leave the keyboard | | X | X / Bksp | Add a device by IP · pick where an incoming transfer lands · delete a history row · erase a character · take every file in the folder | @@ -147,10 +147,13 @@ Settings screen edits everything in it except: - `[transfer] browser_roots` — extra mount points for the file browser - `[transfer] history_limit` — max transfers kept in the History tab (default 200) - `[transfer] pinned_paths`, `last_send_dir` — written by Y and by sending +- `[display] scale` — UI size over what the screen itself asks for (0.6–1.6, + the "UI scale" setting; every screen is drawn for 640×480 and fitted from there) Environment variables override paths and control logging at launch: `RETSEND_DATA_DIR`, `RETSEND_CONFIG`, `RETSEND_SAVE_DIR`, `RETSEND_BROWSER_ROOTS` -(`:`-separated), `RETSEND_ALIAS`, `RETSEND_SCALE`, `RETSEND_GLES=0|1`, +(`:`-separated), `RETSEND_ALIAS`, `RETSEND_SCALE` (pins the base the `[display] +scale` setting is read over, replacing the fit), `RETSEND_GLES=0|1`, `RETSEND_SOFTWARE=1`, `RETSEND_BLIT=1`, `RETSEND_KEYMAP=miyoo|desktop`, `RETSEND_LOG_LEVEL`, `RETSEND_LOG_FILE`, `RETSEND_PANIC_FILE`. diff --git a/android/README.md b/android/README.md index 28a652b..0e7d1c2 100644 --- a/android/README.md +++ b/android/README.md @@ -65,7 +65,8 @@ Logs go to logcat: `adb logcat -s retsend`. PortMaster and OnionOS launchers set, before SDL starts: `RETSEND_DATA_DIR` (internal `getFilesDir()`: config, TLS identity, history), `RETSEND_SAVE_DIR`, `RETSEND_BROWSER_ROOTS` (the storage volumes it can reach), `RETSEND_ALIAS` - (`Build.MODEL`), `RETSEND_SCALE` (display density) and `RETSEND_PANIC_FILE`. + (`Build.MODEL`) and `RETSEND_PANIC_FILE`. Not the scale: fitting the 640×480 + design to the panel already lands on the same physical size at any density. - **Permissions** — `RetsendLauncherActivity` is the launcher entry and asks for all-files access *before* starting SDL, because the paths above are read once at startup and the save folder is persisted on first run. diff --git a/android/app/src/main/java/com/retsend/RetsendActivity.java b/android/app/src/main/java/com/retsend/RetsendActivity.java index a5b4523..44a5ab2 100644 --- a/android/app/src/main/java/com/retsend/RetsendActivity.java +++ b/android/app/src/main/java/com/retsend/RetsendActivity.java @@ -42,9 +42,8 @@ protected void onCreate(Bundle savedInstanceState) { setEnv("RETSEND_BROWSER_ROOTS", browserRoots()); // Only seeds the alias; the Settings screen owns it from then on. setEnv("RETSEND_ALIAS", Build.MODEL); - // Phone screens are dense enough that the UI would be unreadable at 1:1 - // pixels. ~1.0 (mdpi) .. ~3.5 (xxxhdpi), applied to egui's zoom factor. - setEnv("RETSEND_SCALE", String.valueOf(getResources().getDisplayMetrics().density)); + // Not RETSEND_SCALE: fitting the 640x480 design to the panel already + // lands on the same physical size whatever the density is. acquireMulticastLock(); diff --git a/src/app/mod.rs b/src/app/mod.rs index f157215..b3f55ae 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -393,8 +393,13 @@ impl App { /// Nav on a tab: left/right step between tabs the same way L1/R1 do, and /// up/down move the active tab's cursor (Receive has nothing to move). + /// The exception is a settings row carrying a value — there left/right walk + /// the value, and L1/R1 remain the way off the tab. fn tab_nav(&mut self, dir: Direction) { match dir { + Direction::Left | Direction::Right if self.on_stepper_row() => { + self.step_scale(if dir == Direction::Left { -1 } else { 1 }); + } Direction::Left => self.switch_tab(-1), Direction::Right => self.switch_tab(1), Direction::Up | Direction::Down => match self.ui.tabs.active() { @@ -483,6 +488,19 @@ impl App { .or_else(|| (!entry.peer_base.is_empty()).then(|| entry.peer_base.clone())) } + /// Is the cursor on a settings row whose value left/right walk? + fn on_stepper_row(&self) -> bool { + self.ui.tabs.active() == Tab::Settings && self.ui.settings.row().is_stepper() + } + + /// ◂ ▸ on the scale row: nudge the factor over the panel's own fit. The + /// zoom follows on the next frame; the config is written on leaving the tab, + /// like every other setting here. + fn step_scale(&mut self, dir: i32) { + let scale = &mut self.config.display.scale; + *scale = crate::config::display::SCALE.step(*scale, dir); + } + /// A on a settings row: open its editor or toggle it. fn edit_setting(&mut self) { match self.ui.settings.row() { @@ -510,6 +528,8 @@ impl App { self.ui.routes.open(auto); } SettingsRow::About => self.ui.about.open(), + // Walked with ◂ ▸; A on it would have to pick a direction. + SettingsRow::Scale => {} SettingsRow::Port => { self.ui .osk diff --git a/src/config/display.rs b/src/config/display.rs index 22ea883..2794213 100644 --- a/src/config/display.rs +++ b/src/config/display.rs @@ -1,5 +1,15 @@ +use super::Range; use serde::{Deserialize, Serialize}; +/// How far the scale may be pushed either side of the fit. Relative, not +/// absolute: the app already sizes itself to the panel, and a factor that suits +/// a handheld would be wrong on a desktop. +pub const SCALE: Range = Range { + min: 0.6, + max: 1.6, + step: 0.05, +}; + /// Window/display settings (`[display]` in the config). #[derive(Clone, Serialize, Deserialize)] #[serde(default)] @@ -9,6 +19,9 @@ pub struct DisplayConfig { /// Request an OpenGL ES context (required on Mali handhelds) instead of /// desktop GL. Can be overridden at startup via `RETSEND_GLES=0`. pub use_gles: bool, + /// Over the scale the panel's own size asks for, so one setting means the + /// same thing on every device. + pub scale: f32, } impl Default for DisplayConfig { @@ -17,6 +30,7 @@ impl Default for DisplayConfig { width: 640, height: 480, use_gles: true, + scale: 1.0, } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 09f8da5..65042ba 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; mod device; -mod display; +pub mod display; mod input; mod network; mod paths; @@ -23,6 +23,23 @@ pub use network::NetworkConfig; pub use paths::{data_dir, device_scale, env_browser_roots}; pub use transfer::TransferConfig; +/// A settings value the UI walks in steps rather than types. +pub struct Range { + pub min: f32, + pub max: f32, + pub step: f32, +} + +impl Range { + /// Step `value` by `dir` steps and clamp it. Snapped back onto the grid: + /// adding a step over and over drifts, and the drift is what the config + /// file ends up carrying. + pub fn step(&self, value: f32, dir: i32) -> f32 { + let stepped = value + dir as f32 * self.step; + ((stepped / self.step).round() * self.step).clamp(self.min, self.max) + } +} + #[derive(Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct AppConfig { @@ -77,6 +94,12 @@ impl AppConfig { fn sanitize(&mut self) { fix("display.width", &mut self.display.width, 320, 7680); fix("display.height", &mut self.display.height, 240, 4320); + fix_f32( + "display.scale", + &mut self.display.scale, + display::SCALE.min, + display::SCALE.max, + ); // Ports below 1024 need root; 0 means "ephemeral" which would break // re-announce consistency. fix("network.port", &mut self.network.port, 1024, u16::MAX); diff --git a/src/config/paths.rs b/src/config/paths.rs index ea549a2..4b11441 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -1,15 +1,14 @@ //! Path/directory and environment resolution shared across the crate. -/// UI scale factor. The handheld launcher can set `RETSEND_SCALE` for tiny or -/// high-DPI screens; desktop leaves it unset and stays at 1.0. Applied to -/// egui's zoom factor. Clamped to a sane range. -pub fn device_scale() -> f32 { +/// UI scale override from `RETSEND_SCALE`, clamped. `None` — the usual case — +/// leaves the panel to decide; a launcher that knows better says so here, and +/// the `display.scale` setting is still read over it. +pub fn device_scale() -> Option { std::env::var("RETSEND_SCALE") .ok() .and_then(|v| v.parse::().ok()) .filter(|s| s.is_finite() && *s > 0.0) .map(|s| s.clamp(0.5, 6.0)) - .unwrap_or(1.0) } /// The per-user data directory (with a trailing separator) for writable files — diff --git a/src/overlay/settings.rs b/src/overlay/settings.rs index 1ef4d37..196d48b 100644 --- a/src/overlay/settings.rs +++ b/src/overlay/settings.rs @@ -12,12 +12,21 @@ pub enum SettingsRow { AutoRoutes, Routes, Port, + Scale, About, } +impl SettingsRow { + /// Rows whose value ◂ ▸ walk instead of switching tabs. + pub fn is_stepper(self) -> bool { + self == SettingsRow::Scale + } +} + /// Top-to-bottom order on screen; `crate::ui::settings` labels them in the same -/// order, one cursor indexes both. Port is late — it is set once, if ever. -const ROWS: [SettingsRow; 9] = [ +/// order, one cursor indexes both. Port and the scale are late — both are set +/// once, if ever. +const ROWS: [SettingsRow; 10] = [ SettingsRow::Alias, SettingsRow::SaveDir, SettingsRow::QuickSave, @@ -26,6 +35,7 @@ const ROWS: [SettingsRow; 9] = [ SettingsRow::AutoRoutes, SettingsRow::Routes, SettingsRow::Port, + SettingsRow::Scale, SettingsRow::About, ]; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index aeb0cbe..a9d5e39 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -49,6 +49,19 @@ const PROMPT_REFRESH: Duration = Duration::from_millis(100); /// out to `iw`, so not per frame. const NET_STATUS_TTL: Duration = Duration::from_secs(2); +/// The panel every screen is laid out for; the zoom fits it to the real one. +const BASE_SIZE: egui::Vec2 = egui::vec2(640.0, 480.0); +/// Bounds on the zoom actually installed, whatever the fit and the setting +/// between them ask for. +const SCALE_RANGE: std::ops::RangeInclusive = 0.5..=4.0; +const SCALE_STEP: f32 = crate::config::display::SCALE.step; +/// A panel reported past this is a driver answering nonsense, not a screen. +const MAX_PANEL: f32 = 8192.0; +/// How far past a whole number a fit may reach before the fraction is worth it. +/// Fractional zoom lands glyphs between pixels, which reads as uneven type on a +/// panel with no subpixel positioning; the spare pixels become margin instead. +const WHOLE_ZOOM_REACH: f32 = 0.25; + /// Shared ids for the one top and one bottom panel every base screen draws. /// egui paints a red seam at a panel edge whenever a panel id changes between /// frames (its changed-id-between-passes check — see the tab-bar painter note @@ -125,14 +138,21 @@ pub struct AppUi { taps: Vec, /// Throttled IP/SSID for the Receive screen's diagnostic line. net_status: NetStatusCache, + /// `RETSEND_SCALE`, standing in for the panel's own fit where a launcher + /// knows better. + forced_scale: Option, + /// The zoom in force, so a frame that wants the same one installs nothing. + scale: f32, } impl AppUi { pub fn new(sdl: &sdl2::Sdl, config: &crate::config::DisplayConfig) -> Result { let egui = window::open(sdl, config)?; theme::apply(egui.ctx()); - let scale = crate::config::device_scale(); - if scale != 1.0 { + // The fit needs a frame's measurements; a pinned scale is known now, so + // the first frame is already at it rather than at 1.0. + let forced_scale = crate::config::device_scale(); + if let Some(scale) = forced_scale { log::info!("applying RETSEND_SCALE {scale}"); egui.ctx().set_zoom_factor(scale); } @@ -153,6 +173,8 @@ impl AppUi { history_count: 0, taps: Vec::new(), net_status: NetStatusCache::new(), + forced_scale, + scale: forced_scale.unwrap_or(1.0), }) } @@ -171,9 +193,31 @@ impl AppUi { std::mem::take(&mut self.taps) } + /// Scale the whole UI to the panel it is on, then by what the settings ask + /// for. Every size in the renderers is in points against a 640×480 design, + /// so one zoom factor carries the lot — fonts, rows, gaps and all — rather + /// than each screen carrying its own notion of a small display. Re-read + /// every frame, since a desktop window resizes. + fn sync_scale(&mut self, wanted_by_user: f32) { + let ctx = self.egui.ctx(); + let native = panel_points(ctx.content_rect().size(), ctx.zoom_factor()); + if !(1.0..=MAX_PANEL).contains(&native.x) || !(1.0..=MAX_PANEL).contains(&native.y) { + return; + } + let wanted = wanted_scale(native, self.forced_scale, wanted_by_user); + if (wanted - self.scale).abs() < f32::EPSILON { + return; + } + // Two decimals: snapping to the step grid leaves float noise in the tail. + log::info!("ui scale {wanted:.2} for {}x{} points", native.x, native.y); + ctx.set_zoom_factor(wanted); + self.scale = wanted; + } + /// 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) { + self.sync_scale(config.display.scale); let screen = self.screen_data(net, config, history); let prompt_data = prompt_data(net); // The destination picker stands in for the modal while it is up: the @@ -478,6 +522,28 @@ fn endpoint_scheme_port(net: &NetService) -> (String, u16) { ) } +/// The points a panel covers at `zoom` — what the design is fitted against. +fn panel_points(content: egui::Vec2, zoom: f32) -> egui::Vec2 { + (content * zoom).round() +} + +/// The zoom a panel of `native` points wants: what the design fits into it, or +/// `forced` where a launcher said so, taken `user` times over and snapped to a +/// step the layout can settle on. +fn wanted_scale(native: egui::Vec2, forced: Option, user: f32) -> f32 { + // Long edge to long edge: a phone held upright is the same screen, so the + // rows keep their size and the list gets a narrower measure instead. + let fit = + (native.max_elem() / BASE_SIZE.max_elem()).min(native.min_elem() / BASE_SIZE.min_elem()); + let fit = if fit >= 1.0 && fit.fract() <= WHOLE_ZOOM_REACH { + fit.floor() + } else { + fit + }; + let wanted = forced.unwrap_or(fit) * user; + ((wanted / SCALE_STEP).round() * SCALE_STEP).clamp(*SCALE_RANGE.start(), *SCALE_RANGE.end()) +} + /// Unix seconds now — for the history's relative-time labels. fn unix_now() -> u64 { std::time::SystemTime::now() @@ -544,7 +610,147 @@ fn render_toasts(ctx: &egui::Context, toasts: &[String]) { #[cfg(test)] mod tests { - use super::fmt_bytes; + use super::*; + use crate::config::display::SCALE; + + /// The panel the screens were drawn for, a handheld a little past it (the + /// Miyoo Flip), a desktop window, and a phone held upright. + const HANDHELD: egui::Vec2 = BASE_SIZE; + const FLIP: egui::Vec2 = egui::vec2(752.0, 560.0); + const DESKTOP: egui::Vec2 = egui::vec2(1280.0, 720.0); + const UPRIGHT: egui::Vec2 = egui::vec2(BASE_SIZE.y, BASE_SIZE.x); + + /// Snapping multiplies the step back out, so the answers land a rounding + /// short of the round number they read as. + fn assert_scale(got: f32, want: f32) { + assert!((got - want).abs() < SCALE_STEP / 2.0, "{got} is not {want}"); + } + + /// Every scale the setting can be stepped to. + fn user_scales() -> impl Iterator { + let steps = ((SCALE.max - SCALE.min) / SCALE.step).round() as i32; + (0..=steps).map(move |i| SCALE.min + i as f32 * SCALE.step) + } + + #[test] + fn the_design_lands_at_one_on_the_panel_it_was_drawn_for() { + assert_scale(wanted_scale(HANDHELD, None, 1.0), 1.0); + assert_scale(wanted_scale(DESKTOP, None, 1.0), 1.5); + } + + /// The Flip's panel is 17% past the design, which is not worth rendering + /// type between pixels for; the spare pixels become margin instead. + #[test] + fn a_panel_a_little_past_the_design_keeps_a_whole_zoom() { + assert_scale(wanted_scale(FLIP, None, 1.0), 1.0); + // Far enough past it, the fraction is the point. + assert_scale(wanted_scale(DESKTOP, None, 1.0), 1.5); + } + + /// A phone in portrait is the same screen turned, so the rows keep their + /// size and the lists get a narrower measure. + #[test] + fn a_panel_held_upright_asks_for_the_same_scale() { + assert_scale(wanted_scale(UPRIGHT, None, 1.0), 1.0); + for panel in [HANDHELD, FLIP, DESKTOP] { + let upright = egui::vec2(panel.y, panel.x); + for user in user_scales() { + assert_eq!( + wanted_scale(panel, None, user), + wanted_scale(upright, None, user), + "{panel:?} at {user} resizes itself when held upright" + ); + } + } + } + + #[test] + fn the_setting_is_read_against_whatever_the_panel_asked_for() { + // The point of a factor: one setting means the same thing on both. + assert_scale(wanted_scale(HANDHELD, None, 1.2), 1.2); + assert_scale(wanted_scale(DESKTOP, None, 1.2), 1.8); + // A launcher's RETSEND_SCALE is a base like any other. + assert_scale(wanted_scale(DESKTOP, Some(2.0), 1.2), 2.4); + } + + #[test] + fn neither_end_of_the_setting_can_push_the_zoom_out_of_range() { + for panel in [HANDHELD, DESKTOP, egui::vec2(7680.0, 4320.0)] { + for user in [SCALE.min, SCALE.max] { + let scale = wanted_scale(panel, None, user); + assert!( + SCALE_RANGE.contains(&scale), + "{panel:?} at {user} → {scale}" + ); + } + } + } + + /// What `Context::content_rect` hands back for a panel at `zoom`: the + /// points it lays out in, snapped to egui's 1/32pt grid. + fn content_rect(panel: egui::Vec2, zoom: f32) -> egui::Vec2 { + use egui::emath::GuiRounding as _; + (panel / zoom).round_ui() + } + + /// One frame of the loop: measure the panel through the zoom in force, and + /// ask what the next frame should be at. + fn next_zoom(panel: egui::Vec2, zoom: f32, user: f32) -> f32 { + wanted_scale(panel_points(content_rect(panel, zoom), zoom), None, user) + } + + #[test] + fn a_scale_settles_rather_than_swapping_between_two_steps_every_frame() { + // The measurement is taken through the zoom set last frame, so a scale + // landing exactly half a step from the grid could round either way + // depending on the zoom it was measured through — and then change it. + for panel in [HANDHELD, UPRIGHT, FLIP, DESKTOP] { + for user in user_scales() { + let mut zoom = 1.0; + for _ in 0..8 { + zoom = next_zoom(panel, zoom, user); + } + assert_eq!( + next_zoom(panel, zoom, user), + zoom, + "{panel:?} at {user} swaps between the two" + ); + } + } + } + + #[test] + fn every_step_of_the_setting_moves_the_panel_the_design_was_drawn_for() { + // A setting stepping by 5% into a zoom snapped to 10% would spend half + // its presses on nothing at all. + let mut zooms: Vec = user_scales() + .map(|user| wanted_scale(HANDHELD, None, user)) + .collect(); + let asked = zooms.len(); + zooms.dedup(); + assert_eq!(zooms.len(), asked, "{zooms:?} repeats a zoom"); + } + + #[test] + fn a_scale_is_snapped_so_a_resize_drag_does_not_relayout_on_every_pixel() { + // 683×512 fits at 1.066…, and 1.15 asks for a step and a half. + for (panel, user) in [(egui::vec2(683.0, 512.0), 1.0), (HANDHELD, 1.15)] { + let steps = wanted_scale(panel, None, user) / SCALE_STEP; + assert!((steps - steps.round()).abs() < 1e-3, "{steps} is not whole"); + } + } + + #[test] + fn a_step_lands_back_on_the_grid_rather_than_drifting() { + let mut scale = 1.0; + for _ in 0..4 { + scale = SCALE.step(scale, 1); + } + assert_scale(scale, 1.2); + // Neither end walks past itself. + assert_eq!(SCALE.step(SCALE.max, 1), SCALE.max); + assert_eq!(SCALE.step(SCALE.min, -1), SCALE.min); + } #[test] fn fmt_bytes_scales_units() { diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 17d90c0..f7347ef 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -2,7 +2,7 @@ //! row's action in the footer. use super::theme; -use crate::app::AppCommand; +use crate::app::{AppCommand, Direction}; use crate::config::AppConfig; use crate::overlay::settings::Settings; use egui_sdl2::egui; @@ -14,10 +14,15 @@ pub fn render( 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"), - ("Save folder", config.transfer.save_dir.clone(), "Choose"), + // Order matches `crate::overlay::settings::ROWS`; third field is the A verb, + // `None` on the rows ◂ ▸ walk instead. + let rows: [(&str, String, Option<&str>); crate::overlay::settings::ROW_COUNT] = [ + ("Device name", config.device.alias.clone(), Some("Edit")), + ( + "Save folder", + config.transfer.save_dir.clone(), + Some("Choose"), + ), ( "Quick save", if config.transfer.auto_accept { @@ -25,7 +30,7 @@ pub fn render( } else { "off".into() }, - "Toggle", + Some("Toggle"), ), ( "If a file exists", @@ -34,7 +39,7 @@ pub fn render( } else { "keep both — save as name (1)".into() }, - "Toggle", + Some("Toggle"), ), ( "Received folders", @@ -43,12 +48,12 @@ pub fn render( } else { "flatten into the save routes".into() }, - "Toggle", + Some("Toggle"), ), ( "Auto save routes", auto_routes_value(config.transfer.auto_routes, state.auto_route_count), - "Toggle", + Some("Toggle"), ), ( "Save routes", @@ -57,17 +62,18 @@ pub fn render( 1 => "1 extension".into(), n => format!("{n} extensions"), }, - "Edit", + Some("Edit"), ), ( "Port", port_label(config.network.port, actual_port, state.port_dirty), - "Edit", + Some("Edit"), ), + ("UI scale", scale_label(config.display.scale), None), ( "About", format!("retsend {}", env!("CARGO_PKG_VERSION")), - "Open", + Some("Open"), ), ]; @@ -77,15 +83,27 @@ pub fn render( egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { 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", None), - ("A", action, Some(AppCommand::Confirm)), - ], - taps, - ); + match rows[state.cursor.min(rows.len() - 1)].2 { + Some(action) => super::home::hint_bar( + ui, + &[ + ("← →", "Tabs", None), + ("A", action, Some(AppCommand::Confirm)), + ], + taps, + ), + // A stepper row spends ◂ ▸ on its own value, so it names them — + // and naming them is what lets a screen with no pad reach them. + None => super::home::hint_bar( + ui, + &[ + ("◂", "Smaller", Some(AppCommand::Nav(Direction::Left))), + ("▸", "Bigger", Some(AppCommand::Nav(Direction::Right))), + ("L1 R1", "Tabs", None), + ], + taps, + ), + } ui.add_space(4.0); }); @@ -142,6 +160,18 @@ fn port_label(configured: u16, actual: u16, dirty: bool) -> String { } } +/// The factor as a percentage of the fit the panel itself asked for. 100% is +/// that fit, which the row spells out — a bare number says nothing about what +/// it is a percentage of. +fn scale_label(scale: f32) -> String { + let percent = (scale * 100.0).round() as i32; + if percent == 100 { + "100% — fits this screen".to_string() + } else { + format!("{percent}%") + } +} + /// "on" with nothing detected has to say so: that is the desktop case, where the /// setting is live but no console folder exists to route into. fn auto_routes_value(on: bool, folders: usize) -> String { From 6f8f7a2cd1c0f98a730ad19ca831b45691352276 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:50:34 +0300 Subject: [PATCH 2/3] docs: spruceOS among the supported systems --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 16eed4c..d4cf2a5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ client for retro handhelds: send and receive files with your phone or PC over Wi-Fi, no cable or SSH. Compatible with the official LocalSend apps. -It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds and the Miyoo Mini Plus and Flip running OnionOS or Allium, all of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux and on Android handhelds and phones too. +It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds and the Miyoo Mini Plus and Flip running [OnionOS](https://github.com/OnionUI/Onion), [spruceOS](https://github.com/spruceUI/spruceOS) or [Allium](https://github.com/goweiwen/Allium), all of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux and on Android.

Three devices on a couch: a handheld showing the radar of nearby devices, a clamshell handheld waiting to receive, and a phone running the official LocalSend app From 45f8a91e3e140c980b4d003fad31b6a95013f5c4 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:53:46 +0300 Subject: [PATCH 3/3] chore(deps): pem 4, rcgen without default features --- Cargo.lock | 17 +++++++++++------ Cargo.toml | 6 ++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0a6116..9e0e024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,6 +162,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-vec" version = "0.9.1" @@ -944,11 +950,11 @@ dependencies = [ [[package]] name = "pem" -version = "3.0.6" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" dependencies = [ - "base64", + "base64 0.23.1", "serde_core", ] @@ -1056,7 +1062,6 @@ version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ - "pem", "ring", "rustls-pki-types", "time", @@ -1517,7 +1522,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "log", "percent-encoding", "rustls", @@ -1533,7 +1538,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http", "httparse", "log", diff --git a/Cargo.toml b/Cargo.toml index 11b2984..b062a5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,10 +52,12 @@ ureq = { version = "3", default-features = false, features = ["rustls"] } # Patch floor, not a pin: bare "0.23" also admits 0.23.13..=0.23.17, which panic # in Acceptor::accept (RUSTSEC-2024-0399, fixed in 0.23.18). rustls = { version = "0.23.42", default-features = false, features = ["ring", "std"] } -rcgen = "0.14" +# Default features are off only to drop rcgen's `pem`: we read its DER output, +# and that feature pins pem 3, duplicating the crate against our own. +rcgen = { version = "0.14", default-features = false, features = ["ring"] } # Only to hand ureq the stored identity: its client-auth types parse PEM, and # the DER constructor's `KeyKind` is not exported. -pem = "3" +pem = "4" sha2 = "0.11" # Only for SO_REUSEADDR/SO_REUSEPORT before binding UDP 53317 — std can't set # them pre-bind, and without them we can't coexist with the official LocalSend