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
4 changes: 4 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Built-in theme IDs include `terminal`, `catppuccin`, `dracula`, `nord`, `tokyo-n

Choosing **Library** from the main menu loads a compact on-disk snapshot of platforms and merged collections (if present) so the list renders without waiting for the network. A background task refetches endpoints, updates the UI, and writes a fresh snapshot.

The last selected console or collection is restored across launches when that row still exists; otherwise the TUI prefers a row with a complete ROM cache hit, then falls back to the first list entry.

Full ROM lists use the on-disk ROM cache with **stale-while-revalidate**: if a complete cached list exists for the selected console or collection, the Games pane paints it immediately (even when `rom_count` has changed). A background fetch refreshes the list when counts diverge; the pane is not cleared to empty while cache data exists.

OpenAPI compatibility and heartbeat version are loaded from local cache/embedded first so the TUI can paint before contacting the server; a background refresh updates feature gates and server version after first paint.
Expand All @@ -68,6 +70,8 @@ Override snapshot path with `ROMM_LIBRARY_METADATA_SNAPSHOT_PATH` (default: next

This section describes the TUI implementation for contributors. Read alongside `cargo doc -p romm-tui --open` and [architecture.md](architecture.md).

Library selection interpretation lives in the library-browse module: current rows and collection prefetch reuse position-based cache identity and count lookup. ROM request targets and persisted selection derive from that identity; App callers ask the screen for the selected ROM request rather than dispatching on console versus collection.

### Event loop

The heart of the TUI lives in `romm_tui::tui::app::App::run`:
Expand Down
63 changes: 63 additions & 0 deletions romm-api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@ fn default_game_detail_cover_panel_width() -> u16 {
GAME_DETAIL_COVER_PANEL_WIDTH_DEFAULT
}

/// Library left-pane grouping persisted across TUI launches.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LibrarySubsectionKind {
/// Consoles / platforms list.
Console,
/// Collections list (manual, smart, or virtual).
Collection,
}

/// Identity of the selected library list row.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LibrarySelectionKey {
Platform { id: u64 },
Collection { id: u64 },
SmartCollection { id: u64 },
VirtualCollection { id: String },
}

/// Last library list selection restored on TUI open.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LibrarySelectionState {
pub subsection: LibrarySubsectionKind,
pub key: LibrarySelectionKey,
}

/// TUI panel layout preferences (library split and game detail cover width).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TuiLayoutConfig {
Expand All @@ -159,13 +186,17 @@ pub struct TuiLayoutConfig {
/// Game detail: cover column width in terminal cells.
#[serde(default = "default_game_detail_cover_panel_width")]
pub game_detail_cover_panel_width: u16,
/// Last console/collection selection (restored on library open when still present).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub library_selection: Option<LibrarySelectionState>,
}

impl Default for TuiLayoutConfig {
fn default() -> Self {
Self {
library_left_panel_percent: LIBRARY_LEFT_PANEL_PERCENT_DEFAULT,
game_detail_cover_panel_width: GAME_DETAIL_COVER_PANEL_WIDTH_DEFAULT,
library_selection: None,
}
}
}
Expand All @@ -182,6 +213,7 @@ impl TuiLayoutConfig {
GAME_DETAIL_COVER_PANEL_WIDTH_MIN,
GAME_DETAIL_COVER_PANEL_WIDTH_MAX,
),
library_selection: self.library_selection,
}
}
}
Expand Down Expand Up @@ -1293,6 +1325,7 @@ mod tests {
let cfg = TuiLayoutConfig {
library_left_panel_percent: 5,
game_detail_cover_panel_width: 999,
library_selection: None,
}
.normalized();
assert_eq!(
Expand All @@ -1305,6 +1338,36 @@ mod tests {
);
}

#[test]
fn config_library_selection_round_trips() {
let json = r#"{
"base_url":"http://x",
"download_dir":"/tmp",
"use_https":false,
"tui_layout":{
"library_left_panel_percent":30,
"game_detail_cover_panel_width":36,
"library_selection":{
"subsection":"console",
"key":{"type":"platform","id":42}
}
}
}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
let sel = cfg
.tui_layout
.library_selection
.as_ref()
.expect("selection");
assert_eq!(sel.subsection, LibrarySubsectionKind::Console);
assert_eq!(sel.key, LibrarySelectionKey::Platform { id: 42 });
let out = serde_json::to_value(&cfg.tui_layout).unwrap();
assert_eq!(
out["library_selection"]["key"]["type"],
serde_json::json!("platform")
);
}

fn clear_auth_env() {
for key in [
"API_BASE_URL",
Expand Down
6 changes: 6 additions & 0 deletions romm-tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Entries before the workspace split (1.0.0) are filtered from the unified monolit
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Changed

- Consolidate library selection identity, count, and ROM request interpretation without changing browsing, prefetch, or saved-selection behavior.

## [1.4.2](https://github.com/patricksmill/romm-cli/compare/romm-tui-v1.4.1...romm-tui-v1.4.2) (2026-09-28)


Expand Down
4 changes: 2 additions & 2 deletions romm-tui/src/tui/app/background/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,15 +739,15 @@ impl super::super::App {
let selection_reload = if selection_changed && lib.list_len() > 0 {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
let req = lib.get_roms_request();
Some((key, req, expected, "refresh_selection"))
} else {
None
};
let post_scan_reload = if force_reload && lib.list_len() > 0 && !selection_changed {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
let req = lib.get_roms_request();
Some((key, req, expected, "post_scan_reload"))
} else {
None
Expand Down
10 changes: 6 additions & 4 deletions romm-tui/src/tui/app/handlers/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,12 @@ impl App {
let new_key = lib.cache_key();
if old_key != new_key && lib.list_len() > 0 {
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
let req = lib.get_roms_request();
pending_rom_load = Some((new_key, req, expected, "search_filter"));
}
if let Some((key, req, expected, context)) = pending_rom_load {
self.apply_selection_rom_load(key, req, expected, context);
self.persist_library_selection_if_changed();
}
return Ok(false);
}
Expand All @@ -130,7 +131,7 @@ impl App {
if lib.list_len() > 0 {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
let req = lib.get_roms_request();
pending_rom_load = Some((key, req, expected, "list_move_up"));
if lib.subsection
== crate::tui::screens::library_browse::LibrarySubsection::ByCollection
Expand All @@ -149,7 +150,7 @@ impl App {
if lib.list_len() > 0 {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
let req = lib.get_roms_request();
pending_rom_load = Some((key, req, expected, "list_move_down"));
if lib.subsection
== crate::tui::screens::library_browse::LibrarySubsection::ByCollection
Expand Down Expand Up @@ -196,7 +197,7 @@ impl App {
if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
let req = lib.get_roms_request();
pending_rom_load = Some((key, req, expected, "switch_subsection"));
}
if lib.subsection
Expand Down Expand Up @@ -224,6 +225,7 @@ impl App {
}
if let Some((key, req, expected, context)) = pending_rom_load {
self.apply_selection_rom_load(key, req, expected, context);
self.persist_library_selection_if_changed();
}
if prefetch_collections {
self.queue_collection_prefetches_from_screen(1, "library_selection");
Expand Down
13 changes: 9 additions & 4 deletions romm-tui/src/tui/app/handlers/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,26 @@ impl App {
} else if lib.list_len() == 0 {
lib.set_metadata_footer(Some("Loading library metadata…".into()));
}
lib.prefer_first_complete_cache_hit(&self.rom_cache);
let restored = lib.restore_selection(&self.config.tui_layout.library_selection);
if !restored {
lib.prefer_first_complete_cache_hit(&self.rom_cache);
}
self.screen = AppScreen::LibraryBrowse(Box::new(lib));
if let AppScreen::LibraryBrowse(ref lib) = self.screen {
if lib.list_len() > 0 {
let key = lib.cache_key();
let expected = lib.expected_rom_count();
let req = Self::selected_rom_request_for_library(lib);
let req = lib.get_roms_request();
self.apply_selection_rom_load(key, req, expected, "startup_first_selection");
}
}
self.persist_library_selection_if_changed();
self.spawn_library_metadata_refresh();
tracing::debug!(
"library-open latency_ms={} snapshot_hit={}",
"library-open latency_ms={} snapshot_hit={} selection_restored={}",
start.elapsed().as_millis(),
from_disk
from_disk,
restored
);
}

Expand Down
15 changes: 15 additions & 0 deletions romm-tui/src/tui/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,21 @@ impl App {
}
}

/// Update and persist library selection when the list identity changes.
pub(in crate::tui::app) fn persist_library_selection_if_changed(&mut self) {
let AppScreen::LibraryBrowse(ref lib) = self.screen else {
return;
};
let Some(next) = lib.selection_state() else {
return;
};
if self.config.tui_layout.library_selection.as_ref() == Some(&next) {
return;
}
self.config.tui_layout.library_selection = Some(next);
self.persist_tui_layout();
}

#[cfg(test)]
pub(crate) fn theme_id(&self) -> &str {
self.theme.id()
Expand Down
18 changes: 1 addition & 17 deletions romm-tui/src/tui/app/rom_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,27 +157,11 @@ impl super::App {
lib.set_rom_loading(false);
return;
}
(
lib.cache_key(),
Self::selected_rom_request_for_library(lib),
expected,
)
(lib.cache_key(), lib.get_roms_request(), expected)
};
self.queue_primary_rom_load(pending.0, pending.1, pending.2, context);
}

pub(in crate::tui::app) fn selected_rom_request_for_library(
lib: &crate::tui::screens::library_browse::LibraryBrowseScreen,
) -> Option<GetRoms> {
match lib.subsection {
crate::tui::screens::library_browse::LibrarySubsection::ByConsole => {
lib.get_roms_request_platform()
}
crate::tui::screens::library_browse::LibrarySubsection::ByCollection => {
lib.get_roms_request_collection()
}
}
}
pub(in crate::tui::app) fn queue_collection_prefetches_from_screen(
&mut self,
radius: usize,
Expand Down
55 changes: 55 additions & 0 deletions romm-tui/src/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,61 @@ fn open_library_browse_prefers_first_complete_cache_hit() {
assert_eq!(lib.cache_key(), Some(RomCacheKey::Platform(9002)));
}

#[test]
fn open_library_browse_restores_saved_selection_over_cache_prefer() {
use romm_api::config::{LibrarySelectionKey, LibrarySelectionState, LibrarySubsectionKind};
let mut app = app_with_library(vec![]);
let list = RomList {
items: vec![rom_fixture()],
total: 1,
limit: 50,
offset: 0,
};
app.rom_cache
.insert(RomCacheKey::Platform(9301), list.clone(), 1);
app.rom_cache.insert(RomCacheKey::Platform(9302), list, 1);
app.config.tui_layout.library_selection = Some(LibrarySelectionState {
subsection: LibrarySubsectionKind::Console,
key: LibrarySelectionKey::Platform { id: 9302 },
});

if let AppScreen::LibraryBrowse(ref mut lib) = app.screen {
**lib = LibraryBrowseScreen::new(
vec![platform(9301, "FirstCached", 1), platform(9302, "Saved", 1)],
vec![],
LIBRARY_LEFT_PANEL_PERCENT_DEFAULT,
);
let restored = lib.restore_selection(&app.config.tui_layout.library_selection);
assert!(restored);
if !restored {
lib.prefer_first_complete_cache_hit(&app.rom_cache);
}
assert_eq!(lib.cache_key(), Some(RomCacheKey::Platform(9302)));
} else {
panic!("expected library");
}
}

#[tokio::test]
async fn list_move_persists_library_selection_identity() {
use romm_api::config::{LibrarySelectionKey, LibrarySubsectionKind};
let mut app = app_with_library(vec![platform(9401, "NES", 1), platform(9402, "SNES", 1)]);
assert!(app.config.tui_layout.library_selection.is_none());

assert!(!app
.handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::empty()))
.await
.expect("key handled"));

let sel = app
.config
.tui_layout
.library_selection
.expect("selection should persist after list move");
assert_eq!(sel.subsection, LibrarySubsectionKind::Console);
assert_eq!(sel.key, LibrarySelectionKey::Platform { id: 9402 });
}

#[test]
fn ctrl_c_is_treated_as_force_quit() {
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
Expand Down
Loading
Loading