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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Pinned rows sit in a band of their own** in the file browser, closed by a
rule where the folder's own entries start. They lead every listing but belong
to other folders, and a star alone left them reading as rows of the folder
being looked at.

### Fixed

- **The file browser opened inside the app's own folder on the Miyoo CFWs** and
would not leave it. `/mnt/SDCARD` was no known mount point, so the only root
left was `$HOME` — which those launchers point at the app folder for a
writable path — and a root is the one directory B cannot leave. The card is a
root now, and `$HOME` only becomes one when it sits outside every other.

## [0.7.0] - 2026-08-24

### Added
Expand Down
28 changes: 26 additions & 2 deletions src/overlay/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ use std::path::{Path, PathBuf};

/// Mount points worth offering on handheld CFWs, in preference order.
/// Only the ones that exist become roots; `$HOME` covers the desktop.
const ROOT_CANDIDATES: [&str; 5] = [
const ROOT_CANDIDATES: [&str; 6] = [
"/roms",
"/mnt/SDCARD", // the Miyoo card: Onion, Allium, spruce
"/mnt/mmc",
"/mnt/sdcard",
"/userdata/roms",
Expand Down Expand Up @@ -265,6 +266,12 @@ impl FileBrowser {
Some((here.len(), bytes, !all_selected))
}

/// How many rows at the top of the listing are pins. They always lead, so
/// this is also where the folder's own entries begin.
pub fn pinned_rows(&self) -> usize {
self.entries.iter().take_while(|e| e.pinned).count()
}

/// What Y acts on: the row under the cursor, or the folder being looked at
/// when there is no row to point at (an empty listing).
pub fn pin_target(&self) -> Option<PathBuf> {
Expand Down Expand Up @@ -408,7 +415,7 @@ fn build_roots(extra: &[String]) -> Vec<PathBuf> {
}
if let Ok(home) = std::env::var("HOME") {
let home = PathBuf::from(home);
if home.is_dir() && !roots.contains(&home) {
if home.is_dir() && wants_home_root(&roots, &home) {
roots.push(home);
}
}
Expand All @@ -418,6 +425,12 @@ fn build_roots(extra: &[String]) -> Vec<PathBuf> {
roots
}

/// `$HOME` earns a root of its own only outside every other one: the handheld
/// launchers point it at the app folder, and a root is what B cannot leave.
fn wants_home_root(roots: &[PathBuf], home: &Path) -> bool {
!roots.iter().any(|root| home.starts_with(root))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -470,6 +483,8 @@ mod tests {
// The first row is the pinned one, the second the real child directory.
assert!(b.entries[0].pinned);
assert!(!b.entries[1].pinned);
// Where the renderer closes the pinned band.
assert_eq!(b.pinned_rows(), 1);

std::fs::remove_dir_all(&root).unwrap();
}
Expand Down Expand Up @@ -752,6 +767,15 @@ mod tests {
std::fs::remove_dir_all(&root).unwrap();
}

#[test]
fn home_inside_a_root_is_not_a_root_of_its_own() {
let card = PathBuf::from("/mnt/SDCARD");
let roots = vec![card.clone()];
assert!(!wants_home_root(&roots, &card.join("Apps/Retsend.pak")));
assert!(wants_home_root(&roots, Path::new("/home/user")));
assert!(wants_home_root(&[], Path::new("/home/user")));
}

#[test]
fn cursor_clamps() {
let root = temp_tree();
Expand Down
25 changes: 25 additions & 0 deletions src/ui/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const ENTRY_HEIGHT: f32 = 30.0;
/// Below this many seconds left, the incoming request's countdown turns red.
const URGENT_SECS: u32 = 10;

/// The pinned band: an accent wash faint enough to stay under the cursor's own
/// 0.30 fill, the rule closing it, and the padding it grows by past its rows.
const BAND_TINT: f32 = 0.10;
const RULE_TINT: f32 = 0.55;
const BAND_INSET: f32 = 2.0;

/// `deadline_secs` is set only while the browser is picking a destination for
/// a parked incoming request: the modal (and its countdown bar) is hidden
/// behind us, so the seconds left have to show up here.
Expand Down Expand Up @@ -130,6 +136,25 @@ pub fn render(
if browser.cursor < total {
ui.scroll_to_rect(row_rect(browser.cursor), None);
}
// Pins lead the listing but belong to other folders: a band behind
// them and a rule under the last one keep them from reading as rows
// of the folder being looked at.
let pins = browser.pinned_rows();
if pins > 0 {
let band = row_rect(0).union(row_rect(pins - 1));
ui.painter().rect_filled(
band.expand2(egui::vec2(0.0, BAND_INSET)),
4.0,
theme::ACCENT.linear_multiply(BAND_TINT),
);
if pins < total {
ui.painter().hline(
band.x_range(),
band.max.y + spacing * 0.5,
egui::Stroke::new(1.0, theme::DIM.linear_multiply(RULE_TINT)),
);
}
}
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,
Expand Down
Loading