diff --git a/docs/releases.md b/docs/releases.md index d7bbbe4..3aa3a77 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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`. diff --git a/docs/tui.md b/docs/tui.md index c4fcb8a..8de056f 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -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 diff --git a/romm-tui/CHANGELOG.md b/romm-tui/CHANGELOG.md index 8ce831a..68b394a 100644 --- a/romm-tui/CHANGELOG.md +++ b/romm-tui/CHANGELOG.md @@ -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) diff --git a/romm-tui/src/tui/app/render.rs b/romm-tui/src/tui/app/render.rs index 534a444..3af6c30 100644 --- a/romm-tui/src/tui/app/render.rs +++ b/romm-tui/src/tui/app/render.rs @@ -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)); - } } } diff --git a/romm-tui/src/tui/path_picker.rs b/romm-tui/src/tui/path_picker.rs index 1f11f71..90a38a6 100644 --- a/romm-tui/src/tui/path_picker.rs +++ b/romm-tui/src/tui/path_picker.rs @@ -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 { @@ -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, @@ -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 diff --git a/romm-tui/src/tui/screens/library_browse/render.rs b/romm-tui/src/tui/screens/library_browse/render.rs index e2e0c93..e23e3e9 100644 --- a/romm-tui/src/tui/screens/library_browse/render.rs +++ b/romm-tui/src/tui/screens/library_browse/render.rs @@ -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}; @@ -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]); @@ -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]); @@ -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(); diff --git a/romm-tui/src/tui/screens/metadata_match.rs b/romm-tui/src/tui/screens/metadata_match.rs index 23873c0..9c89237 100644 --- a/romm-tui/src/tui/screens/metadata_match.rs +++ b/romm-tui/src/tui/screens/metadata_match.rs @@ -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]); } diff --git a/romm-tui/src/tui/screens/search.rs b/romm-tui/src/tui/screens/search.rs index 50e1638..ed7e41d 100644 --- a/romm-tui/src/tui/screens/search.rs +++ b/romm-tui/src/tui/screens/search.rs @@ -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")); @@ -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 { diff --git a/romm-tui/src/tui/screens/settings/render.rs b/romm-tui/src/tui/screens/settings/render.rs index 5d964f9..2e14ed3 100644 --- a/romm-tui/src/tui/screens/settings/render.rs +++ b/romm-tui/src/tui/screens/settings/render.rs @@ -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 { @@ -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::>(); 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); @@ -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() => { @@ -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 { @@ -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([ diff --git a/romm-tui/src/tui/screens/setup_wizard/render.rs b/romm-tui/src/tui/screens/setup_wizard/render.rs index cf3358a..4c74da1 100644 --- a/romm-tui/src/tui/screens/setup_wizard/render.rs +++ b/romm-tui/src/tui/screens/setup_wizard/render.rs @@ -12,15 +12,20 @@ use romm_api::config::normalize_romm_origin; use super::layout::{wizard_footer_entries, wizard_footer_text, wizard_layout}; use super::types::{AuthKind, SetupWizard, Step}; -fn cursor_line(text: &str, cursor: usize) -> String { - let (before, after) = text_cursor::split_at_cursor(text, cursor); - format!("{before}▏{after}") -} - fn secret_mask(text: &str) -> String { "•".repeat(text.chars().count()) } +/// Renders the mask for an active secret field with the inline caret at the +/// cursor, translating the byte cursor in the secret into the corresponding +/// byte offset in the bullet mask. +fn masked_cursor_line(text: &str, cursor: usize, width: usize) -> String { + let mask = secret_mask(text); + let masked_chars = text_cursor::split_at_cursor(text, cursor).0.chars().count(); + let mask_cursor = masked_chars * '•'.len_utf8(); + text_cursor::cursor_line(&mask, mask_cursor, width) +} + impl SetupWizard { pub fn render( &mut self, @@ -78,8 +83,9 @@ impl SetupWizard { match self.step { Step::Url => { - let text = cursor_line(&self.url, self.url_cursor); let block = styles.panel_block(title); + let width = block.inner(main[1]).width as usize; + let text = text_cursor::cursor_line(&self.url, self.url_cursor, width); let p = Paragraph::new(text).style(styles.text()).block(block); f.render_widget(p, main[1]); } @@ -121,12 +127,18 @@ impl SetupWizard { f.render_stateful_widget(list, main[1], &mut state); } Step::BasicUser | Step::BasicPass => { + let block = styles.panel_block(title); + let width = block.inner(main[1]).width as usize; let user_line = if self.step == Step::BasicUser { - cursor_line(&self.username, self.user_cursor) + text_cursor::cursor_line(&self.username, self.user_cursor, width) } else { self.username.clone() }; - let pass_display = secret_mask(&self.password); + let pass_display = if self.step == Step::BasicPass { + masked_cursor_line(&self.password, self.password.len(), width) + } else { + secret_mask(&self.password) + }; let kr_hint = if self.step == Step::BasicPass && self.password.is_empty() && self.reuse_keyring_password @@ -135,7 +147,6 @@ impl SetupWizard { } else { "" }; - let block = styles.panel_block(title); let body = format!( "Username\n{user_line}\n\nPassword (hidden)\n{pass_display}{kr_hint}\n\nTab: switch field" ); @@ -143,7 +154,9 @@ impl SetupWizard { f.render_widget(p, main[1]); } Step::Bearer => { - let line = cursor_line(&self.bearer_token, self.bearer_cursor); + let block = styles.panel_block(title); + let width = block.inner(main[1]).width as usize; + let line = text_cursor::cursor_line(&self.bearer_token, self.bearer_cursor, width); let mut bearer_text = Text::from(vec![ Line::from("API Token"), Line::from(""), @@ -156,26 +169,32 @@ impl SetupWizard { styles.muted(), ))); } - let block = styles.panel_block(title); let p = Paragraph::new(bearer_text) .style(styles.text()) .block(block); f.render_widget(p, main[1]); } Step::PairingCode => { - let line = cursor_line(&self.pairing_code, self.pairing_cursor); - let body = format!("Enter the 8-character code provided.\n\n{line}"); let block = styles.panel_block(title); + let width = block.inner(main[1]).width as usize; + let line = text_cursor::cursor_line(&self.pairing_code, self.pairing_cursor, width); + let body = format!("Enter the 8-character code provided.\n\n{line}"); let p = Paragraph::new(body).style(styles.text()).block(block); f.render_widget(p, main[1]); } Step::ApiHeader | Step::ApiKey => { + let block = styles.panel_block(title); + let width = block.inner(main[1]).width as usize; let header_line = if self.step == Step::ApiHeader { - cursor_line(&self.api_header, self.header_cursor) + text_cursor::cursor_line(&self.api_header, self.header_cursor, width) } else { self.api_header.clone() }; - let key_line = secret_mask(&self.api_key); + let key_line = if self.step == Step::ApiKey { + masked_cursor_line(&self.api_key, self.api_key_cursor, width) + } else { + secret_mask(&self.api_key) + }; let kr_hint = if self.step == Step::ApiKey && self.api_key.is_empty() && self.reuse_keyring_api_key @@ -187,7 +206,6 @@ impl SetupWizard { let body = format!( "Header name\n{header_line}\n\nKey (hidden)\n{key_line}{kr_hint}\n\nTab: switch field" ); - let block = styles.panel_block(title); let p = Paragraph::new(body).style(styles.text()).block(block); f.render_widget(p, main[1]); } @@ -245,52 +263,4 @@ impl SetupWizard { let p = Paragraph::new(footer_text).block(block); f.render_widget(p, main[2]); } - - pub fn cursor_pos(&self, area: ratatui::layout::Rect) -> Option<(u16, u16)> { - let main = wizard_layout(area, self.step); - let inner = main[1]; - match self.step { - Step::Url => { - let x = inner.x + 1 + text_cursor::char_column(&self.url, self.url_cursor) as u16; - Some((x, inner.y + 1)) - } - Step::Download => self - .download_picker - .cursor_position(inner, "Step 3/6 — ROMs directory"), - Step::Bearer => { - let x = inner.x - + 1 - + text_cursor::char_column(&self.bearer_token, self.bearer_cursor) as u16; - Some((x, inner.y + 1)) - } - Step::PairingCode => { - let x = inner.x - + 1 - + text_cursor::char_column(&self.pairing_code, self.pairing_cursor) as u16; - Some((x, inner.y + 3)) - } - Step::BasicUser => { - let x = - inner.x + 1 + text_cursor::char_column(&self.username, self.user_cursor) as u16; - Some((x, inner.y + 2)) - } - Step::BasicPass => { - let x = inner.x + 1 + self.password.chars().count() as u16; - Some((x, inner.y + 6)) - } - Step::ApiHeader => { - let x = inner.x - + 1 - + text_cursor::char_column(&self.api_header, self.header_cursor) as u16; - Some((x, inner.y + 2)) - } - Step::ApiKey => { - let x = inner.x - + 1 - + text_cursor::char_column(&self.api_key, self.api_key_cursor) as u16; - Some((x, inner.y + 6)) - } - Step::Https | Step::CustomConsolePaths | Step::AuthMenu | Step::Summary => None, - } - } } diff --git a/romm-tui/src/tui/screens/setup_wizard/run.rs b/romm-tui/src/tui/screens/setup_wizard/run.rs index 78608f3..5afac87 100644 --- a/romm-tui/src/tui/screens/setup_wizard/run.rs +++ b/romm-tui/src/tui/screens/setup_wizard/run.rs @@ -22,9 +22,6 @@ impl SetupWizard { session.terminal_mut().draw(|f| { let area = f.area(); self.render(f, area, &styles); - if let Some((x, y)) = self.cursor_pos(area) { - f.set_cursor_position((x, y)); - } })?; if event::poll(std::time::Duration::from_millis(100))? { diff --git a/romm-tui/src/tui/screens/setup_wizard/tests.rs b/romm-tui/src/tui/screens/setup_wizard/tests.rs index 86bbdb8..a157b3b 100644 --- a/romm-tui/src/tui/screens/setup_wizard/tests.rs +++ b/romm-tui/src/tui/screens/setup_wizard/tests.rs @@ -1,6 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::backend::TestBackend; -use ratatui::layout::Rect; use ratatui::Terminal; use std::path::PathBuf; use wiremock::matchers::{method, path}; @@ -26,6 +25,30 @@ fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::empty()) } +/// Renders the wizard into a `TestBackend` and returns the visible screen text. +fn render_wizard(wizard: &mut SetupWizard) -> String { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("create test terminal"); + let theme = resolve_theme_or_default(&default_theme_id()); + let styles = RommStyles::new(theme.as_ref()); + terminal + .draw(|frame| { + let area = frame.area(); + wizard.render(frame, area, &styles); + }) + .expect("render setup wizard"); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + let mut screen = String::new(); + for (index, cell) in buffer.content().iter().enumerate() { + if width > 0 && index > 0 && index % width == 0 { + screen.push('\n'); + } + screen.push_str(cell.symbol()); + } + screen +} + fn wizard_with_pairing(mock_uri: &str, code: &str, download_dir: &str) -> SetupWizard { SetupWizard { step: Step::PairingCode, @@ -77,17 +100,15 @@ fn url_input_keeps_cursor_on_utf8_boundaries() { } #[test] -fn url_cursor_position_counts_utf8_chars_not_bytes() { - let area = Rect::new(0, 0, 80, 24); +fn url_renders_inline_caret_at_utf8_cursor() { let mut wizard = SetupWizard::new(); - wizard.url.clear(); - wizard.url_cursor = 0; - let base_x = wizard.cursor_pos(area).expect("url cursor").0; - wizard.url = "éx".to_string(); wizard.url_cursor = wizard.url.len(); + assert!(render_wizard(&mut wizard).contains("éx▏")); - assert_eq!(wizard.cursor_pos(area).expect("url cursor").0, base_x + 2); + wizard.url = "aé".to_string(); + wizard.url_cursor = "a".len(); + assert!(render_wizard(&mut wizard).contains("a▏é")); } #[tokio::test] @@ -135,84 +156,50 @@ async fn pairing_config_from_exchange_returns_bearer_token() { } #[test] -fn hidden_password_field_does_not_render_inline_cursor_glyph() { +fn hidden_password_renders_mask_with_inline_caret() { let mut wizard = SetupWizard::new(); wizard.step = Step::BasicPass; wizard.password = "secret".to_string(); - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("create test terminal"); - let theme = resolve_theme_or_default(&default_theme_id()); - let styles = RommStyles::new(theme.as_ref()); - terminal - .draw(|frame| { - let area = frame.area(); - wizard.render(frame, area, &styles); - }) - .expect("render setup wizard"); - let backend = terminal.backend(); - let buffer = backend.buffer(); - let has_cursor_glyph = buffer.content().iter().any(|cell| cell.symbol() == "▏"); + let screen = render_wizard(&mut wizard); + assert!( + screen.contains("••••••▏"), + "active password field should mask the secret and show an inline caret at the end" + ); assert!( - !has_cursor_glyph, - "password field should rely on terminal cursor, not inline glyph" + !screen.contains("secret"), + "password text must never be rendered" ); } #[test] -fn hidden_password_mask_and_cursor_count_utf8_chars_not_bytes() { - let area = Rect::new(0, 0, 80, 24); +fn hidden_password_mask_counts_utf8_chars_not_bytes() { let mut wizard = SetupWizard::new(); wizard.step = Step::BasicPass; wizard.password = "é".to_string(); - - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("create test terminal"); - let theme = resolve_theme_or_default(&default_theme_id()); - let styles = RommStyles::new(theme.as_ref()); - terminal - .draw(|frame| { - wizard.render(frame, area, &styles); - }) - .expect("render setup wizard"); - let bullet_count = terminal - .backend() - .buffer() - .content() - .iter() - .filter(|cell| cell.symbol() == "•") - .count(); - assert_eq!(bullet_count, 1); - - wizard.password.clear(); - let base_x = wizard.cursor_pos(area).expect("password cursor").0; - wizard.password = "é".to_string(); - assert_eq!( - wizard.cursor_pos(area).expect("password cursor").0, - base_x + 1 - ); + let screen = render_wizard(&mut wizard); + assert_eq!(screen.matches('•').count(), 1); + assert!(screen.contains("•▏")); } #[test] -fn hidden_api_key_field_does_not_render_inline_cursor_glyph() { +fn hidden_api_key_renders_mask_with_inline_caret_at_middle_cursor() { let mut wizard = SetupWizard::new(); wizard.step = Step::ApiKey; - wizard.api_key = "secret-key".to_string(); + wizard.api_key = "abcd".to_string(); wizard.api_key_cursor = wizard.api_key.len(); - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("create test terminal"); - let theme = resolve_theme_or_default(&default_theme_id()); - let styles = RommStyles::new(theme.as_ref()); - terminal - .draw(|frame| { - let area = frame.area(); - wizard.render(frame, area, &styles); - }) - .expect("render setup wizard"); - let backend = terminal.backend(); - let buffer = backend.buffer(); - let has_cursor_glyph = buffer.content().iter().any(|cell| cell.symbol() == "▏"); + wizard.handle_key(&key(KeyCode::Left)).unwrap(); + wizard.handle_key(&key(KeyCode::Left)).unwrap(); + wizard.handle_key(&key(KeyCode::Char('X'))).unwrap(); + assert_eq!(wizard.api_key, "abXcd"); + assert_eq!(wizard.api_key_cursor, 3); + + let screen = render_wizard(&mut wizard); + assert!( + screen.contains("•••▏••"), + "active API key field should place the caret at the edited character position" + ); assert!( - !has_cursor_glyph, - "API key field should rely on terminal cursor, not inline glyph" + !screen.contains("abXcd"), + "API key text must never be rendered" ); } diff --git a/romm-tui/src/tui/text_cursor.rs b/romm-tui/src/tui/text_cursor.rs index 34a680a..bfeeaa1 100644 --- a/romm-tui/src/tui/text_cursor.rs +++ b/romm-tui/src/tui/text_cursor.rs @@ -64,7 +64,32 @@ pub(crate) fn split_at_cursor(s: &str, cursor: usize) -> (&str, &str) { s.split_at(pos) } -pub(crate) fn char_column(s: &str, cursor: usize) -> usize { - let pos = clamp_to_char_boundary(s, cursor); - s[..pos].chars().count() +/// Render an active single-line field, keeping the insertion marker in view. +pub(crate) fn cursor_line(text: &str, cursor: usize, width: usize) -> String { + use unicode_width::UnicodeWidthChar; + + if width == 0 { + return String::new(); + } + let (before, after) = split_at_cursor(text, cursor); + let mut used = 1; // Reserve one terminal cell for the caret. + let mut start = before.len(); + for (index, ch) in before.char_indices().rev() { + let cells = ch.width().unwrap_or(0); + if used + cells > width { + break; + } + used += cells; + start = index; + } + let mut end = 0; + for (index, ch) in after.char_indices() { + let cells = ch.width().unwrap_or(0); + if used + cells > width { + break; + } + used += cells; + end = index + ch.len_utf8(); + } + format!("{}▏{}", &before[start..], &after[..end]) }