Skip to content
Open
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: 2 additions & 2 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ After merging:
- [ ] [Release Please publish job](../.github/workflows/release-please.yml) completed (`publish-crates-io`)
- [ ] [Release Artifacts](../.github/workflows/release-artifacts.yml) completed for frontend tags
- [ ] crates.io shows the new version(s)
- [ ] Smoke test: download archives and run `romm-cli --version` / `romm-tui --version`
- [ ] Smoke test: download archives, run `romm-cli --version`, and launch `romm-tui` in a terminal to verify startup and exit. The TUI binary does not parse `--version`.

Local preflight:

```bash
./tools/release-check.sh
bash tools/release-check.sh
```

`release-check` always dry-runs `romm-api` first. Frontend `cargo publish --dry-run` runs only after that `romm-api` version exists on crates.io; otherwise frontends are validated with `cargo build --release`.
Expand Down
2 changes: 2 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ On startup, the TUI checks for newer releases (disable with `ROMM_CHECK_UPDATES=
- **Settings** — auth, paths, appearance, save-sync options
- **Save downloads** — per-console save paths (see [api.md — custom save paths](api.md#custom-console-save-paths))

Active text-entry fields show an inline `▏` caret by default, including metadata search, game search, library filters, settings, path pickers, and setup/authentication forms. Long values scroll to keep the insertion point visible; password and API-key fields remain masked.

### Screenshots

#### Game details view
Expand Down
4 changes: 4 additions & 0 deletions romm-tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

### Fixed

- Show a visible typing caret by default across search, filters, settings, path pickers, and setup/authentication forms, including masked secret fields; keep the insertion point visible for long inputs.

## [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
12 changes: 0 additions & 12 deletions romm-tui/src/tui/app/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,18 @@ impl super::App {
match &mut self.screen {
AppScreen::LibraryBrowse(lib) => {
lib.render(f, area, &styles);
if let Some((x, y)) = lib.upload_prompt_cursor(area) {
f.set_cursor_position((x, y));
}
}
AppScreen::Search(search) => {
search.render(f, area, &styles);
if let Some((x, y)) = search.cursor_position(area) {
f.set_cursor_position((x, y));
}
}
AppScreen::Settings(settings) => {
settings.render(f, area, &styles);
if let Some((x, y)) = settings.cursor_position(area) {
f.set_cursor_position((x, y));
}
}
AppScreen::GameDetail(detail) => detail.render(f, area, &styles),
AppScreen::MetadataMatch(picker) => picker.render(f, area, &styles),
AppScreen::Download(d) => d.render(f, area, &styles),
AppScreen::SetupWizard(wizard) => {
wizard.render(f, area, &styles);
if let Some((x, y)) = wizard.cursor_pos(area) {
f.set_cursor_position((x, y));
}
}
}

Expand Down
38 changes: 12 additions & 26 deletions romm-tui/src/tui/path_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use ratatui::Frame;

use crate::tui::footer_hint::{footer_hint_line, FooterHintEntry};
use crate::tui::text_cursor;
use crate::tui::theme::RommStyles;

/// Display width of the path-bar focus prefix ("▶ ") in terminal cells.
const PATH_PREFIX_WIDTH: usize = 2;

/// Whether the user must pick a directory or a regular file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathPickerMode {
Expand Down Expand Up @@ -407,13 +410,19 @@ impl PathPicker {
PathPickerFocus::PathBar => "▶ ",
PathPickerFocus::List => " ",
};
let (before, after) = text_cursor::split_at_cursor(&self.path_text, self.path_cursor);
let path_style = if self.focus == PathPickerFocus::PathBar {
styles.selection()
} else {
styles.text()
};
let path_line = format!("{path_prefix}{before}▏{after}");
let path_text_width = usize::from(chunks[0].width).saturating_sub(PATH_PREFIX_WIDTH);
let path_line = match self.focus {
PathPickerFocus::PathBar => format!(
"{path_prefix}{}",
text_cursor::cursor_line(&self.path_text, self.path_cursor, path_text_width)
),
PathPickerFocus::List => format!("{path_prefix}{}", self.path_text),
};
let path_row = Rect {
x: chunks[0].x,
y: chunks[0].y,
Expand Down Expand Up @@ -467,29 +476,6 @@ impl PathPicker {
chunks[2],
);
}

/// Terminal cursor inside path bar (relative to `area` passed to `render`).
pub fn cursor_position(&self, area: Rect, title: &str) -> Option<(u16, u16)> {
if self.focus != PathPickerFocus::PathBar {
return None;
}
let block = Block::default().title(title).borders(Borders::ALL);
let inner = block.inner(area);
let path_h = if self.io_error.is_some() { 2u16 } else { 1u16 };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(path_h),
Constraint::Min(3),
Constraint::Length(2),
])
.split(inner);
let path_prefix_chars = 2u16; // "▶ "
let path_before = text_cursor::char_column(&self.path_text, self.path_cursor) as u16;
let x = chunks[0].x + path_prefix_chars + path_before;
let y = chunks[0].y;
Some((x.min(chunks[0].x + chunks[0].width.saturating_sub(1)), y))
}
}

/// If the user is typing past `browse_dir` into a new path segment, return that segment's
Expand Down
33 changes: 15 additions & 18 deletions romm-tui/src/tui/screens/library_browse/render.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::widgets::{Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table};
use ratatui::widgets::{Cell, List, ListItem, ListState, Paragraph, Row, Table};
use ratatui::Frame;

use crate::tui::footer_hint::{render_footer_panel, FooterHintEntry};
Expand Down Expand Up @@ -148,7 +148,13 @@ impl LibraryBrowseScreen {
LibrarySearchMode::Filter => "Filter (list)",
LibrarySearchMode::Jump => "Jump (list)",
};
let p = Paragraph::new(format!("Search: {}", self.list_search.query))
let input = format!("Search: {}", self.list_search.query);
let input = crate::tui::text_cursor::cursor_line(
&input,
input.len(),
usize::from(left_chunks[0].width.saturating_sub(2)),
);
let p = Paragraph::new(input)
.style(styles.text())
.block(styles.panel_block(title));
f.render_widget(p, left_chunks[0]);
Expand Down Expand Up @@ -179,7 +185,13 @@ impl LibraryBrowseScreen {
LibrarySearchMode::Filter => "Filter (games)",
LibrarySearchMode::Jump => "Jump (games)",
};
let p = Paragraph::new(format!("Search: {}", self.rom_search.query))
let input = format!("Search: {}", self.rom_search.query);
let input = crate::tui::text_cursor::cursor_line(
&input,
input.len(),
usize::from(right_chunks[0].width.saturating_sub(2)),
);
let p = Paragraph::new(input)
.style(styles.text())
.block(styles.panel_block(title));
f.render_widget(p, right_chunks[0]);
Expand Down Expand Up @@ -244,21 +256,6 @@ impl LibraryBrowseScreen {
.render(f, rows[1], "Choose ROM file", UPLOAD_PICKER_HINTS, styles);
}

/// Cursor for the upload path field (when [`Self::upload_prompt`] is open).
pub fn upload_prompt_cursor(&self, area: Rect) -> Option<(u16, u16)> {
let up = self.upload_prompt.as_ref()?;
let popup = Self::upload_popup_rect(area);
let inner = Block::default()
.title("Upload ROM (Ctrl+u)")
.borders(Borders::ALL)
.inner(popup);
let rows = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(8)])
.split(inner);
up.picker.cursor_position(rows[1], "Choose ROM file")
}

fn render_list(&self, f: &mut Frame, area: Rect, styles: &RommStyles) {
let visible = self.visible_list_source_indices();
let labels = self.list_row_labels();
Expand Down
5 changes: 5 additions & 0 deletions romm-tui/src/tui/screens/metadata_match.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ impl MetadataMatchScreen {
match &self.phase {
MetadataMatchPhase::QueryInput => {
let input_line = format!("Search: {}", self.search_query);
let input_line = crate::tui::text_cursor::cursor_line(
&input_line,
"Search: ".len() + self.cursor_pos,
usize::from(block.inner(chunks[0]).width),
);
let p = Paragraph::new(input_line).style(styles.text()).block(block);
f.render_widget(p, chunks[0]);
}
Expand Down
21 changes: 5 additions & 16 deletions romm-tui/src/tui/screens/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ impl SearchScreen {
.split(area);

let input_line = format!("Search: {}", self.query);
let input_line = crate::tui::text_cursor::cursor_line(
&input_line,
"Search: ".len() + self.cursor_pos,
usize::from(chunks[0].width.saturating_sub(2)),
);
let input = Paragraph::new(input_line)
.style(styles.text())
.block(styles.panel_block("Search games"));
Expand Down Expand Up @@ -257,22 +262,6 @@ impl SearchScreen {
None,
);
}

pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
let chunks = Layout::default()
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
])
.direction(ratatui::layout::Direction::Vertical)
.split(area);
let pos = clamp_to_char_boundary(&self.query, self.cursor_pos);
let offset = 9 + self.query[..pos].chars().count() as u16;
let x = chunks[0].x + offset.min(chunks[0].width.saturating_sub(1));
let y = chunks[0].y + 1;
Some((x, y))
}
}

fn clamp_to_char_boundary(s: &str, pos: usize) -> usize {
Expand Down
77 changes: 31 additions & 46 deletions romm-tui/src/tui/screens/settings/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ const CONSOLE_PICKER_HINTS: &[FooterHintEntry] = &[
},
];

/// Label prefix for the Base URL row, including its value spacing.
const BASE_URL_LABEL: &str = "Base URL: ";

/// Highlight marker rendered in the gutter of the settings list.
const LIST_HIGHLIGHT_SYMBOL: &str = ">> ";

impl SettingsScreen {
pub fn render(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
if let Some((platform_id, ref mut picker)) = self.console_path_picker {
Expand Down Expand Up @@ -219,20 +225,26 @@ impl SettingsScreen {
f.render_widget(tabs, chunks[1]);

// -- Editable List --
let list_block = styles.panel_block(format!(" {} ", self.selected_tab.title()));
// The list's actual content width, excluding the border and highlight gutter.
let row_width = list_block
.inner(chunks[2])
.width
.saturating_sub(LIST_HIGHLIGHT_SYMBOL.len() as u16);
let items = self
.visible_rows()
.iter()
.copied()
.map(|row| self.render_row_item(row, styles))
.map(|row| self.render_row_item(row, styles, row_width))
.collect::<Vec<_>>();

let mut state = ListState::default();
state.select(Some(self.selected_row_index()));

let list = List::new(items)
.block(styles.panel_block(format!(" {} ", self.selected_tab.title())))
.block(list_block)
.highlight_style(styles.selection())
.highlight_symbol(">> ");
.highlight_symbol(LIST_HIGHLIGHT_SYMBOL);

f.render_stateful_widget(list, chunks[2], &mut state);

Expand Down Expand Up @@ -278,7 +290,21 @@ impl SettingsScreen {
render_footer_panel(f, chunks[4], styles, help, None);
}

fn render_row_item(&self, row: SettingsRow, styles: &RommStyles) -> ListItem<'static> {
fn render_row_item(
&self,
row: SettingsRow,
styles: &RommStyles,
row_width: u16,
) -> ListItem<'static> {
if self.editing
&& row == SettingsRow::BaseUrl
&& self.selected_row() == SettingsRow::BaseUrl
{
let value_width = usize::from(row_width).saturating_sub(BASE_URL_LABEL.len());
let value = text_cursor::cursor_line(&self.edit_buffer, self.edit_cursor, value_width);
return ListItem::new(format!("{BASE_URL_LABEL}{value}")).style(styles.text());
}

let label = self.row_label(row);
match row {
SettingsRow::SyncDevice | SettingsRow::SyncNow if !self.save_sync_supported() => {
Expand All @@ -291,7 +317,7 @@ impl SettingsScreen {
pub(crate) fn row_label(&self, row: SettingsRow) -> String {
let label = match row {
SettingsRow::BaseUrl => format!(
"Base URL: {}",
"{BASE_URL_LABEL}{}",
if self.editing && self.selected_row() == SettingsRow::BaseUrl {
&self.edit_buffer
} else {
Expand Down Expand Up @@ -360,47 +386,6 @@ impl SettingsScreen {
}
}

pub fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> {
if let Some((kind, ref picker)) = self.path_picker {
let chunks = Layout::default()
.constraints([
Constraint::Length(4),
Constraint::Min(12),
Constraint::Length(3),
])
.direction(ratatui::layout::Direction::Vertical)
.split(area);
let title = match kind {
SettingsPickerKind::RomsDir => "Choose ROMs directory",
SettingsPickerKind::SaveDir => "Choose save directory",
};
return picker.cursor_position(chunks[1], title);
}

if !self.editing || self.selected_row() != SettingsRow::BaseUrl {
return None;
}

let chunks = Layout::default()
.constraints([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(3),
Constraint::Length(3),
])
.direction(ratatui::layout::Direction::Vertical)
.split(area);

let list_area = chunks[2];
let y = list_area.y + 1 + self.selected_row_index() as u16;
let label_len = 14; // "Base URL: ".len()
let edit_col = text_cursor::char_column(&self.edit_buffer, self.edit_cursor) as u16;
let x = list_area.x + 1 /* border */ + 3 /* highlight symbol */ + label_len + edit_col;

Some((x, y))
}

fn render_device_picker(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
let chunks = Layout::default()
.constraints([
Expand Down
Loading
Loading