diff options
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/dialogs.rs | 254 |
1 files changed, 233 insertions, 21 deletions
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index dae1c67a..ff6ced3d 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -40,6 +40,41 @@ pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { (rects[0], rects[1], rects[2]) } +/// Draws the shared `name_input` field and puts the host cursor on its caret. +/// +/// IMEs draw their composition preview at the host terminal cursor. Without an +/// explicit cursor the frame carries none, the client keeps the position last +/// reported by the focused pane, and composition lands behind the dialog. +fn render_name_input_field(app: &AppState, frame: &mut Frame, input_rect: Rect) { + frame.render_widget(Clear, input_rect); + + // The text stops one column short of the field so the clamped caret always + // lands on a blank cell: a host terminal inverts the cell under its cursor, + // and an IME composes there. + let text_rect = Rect { + width: input_rect.width.saturating_sub(1), + ..input_rect + }; + frame.render_widget( + Paragraph::new(format!(" {}", app.name_input)).style( + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0), + ), + text_rect, + ); + + if input_rect.width == 0 { + return; + } + let caret_x = input_rect + .x + .saturating_add(1) + .saturating_add(display_width_u16(&app.name_input)) + .min(input_rect.right().saturating_sub(1)); + frame.set_cursor_position((caret_x, input_rect.y)); +} + pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { super::dim_background(frame, area); @@ -71,15 +106,7 @@ pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rec render_modal_header(frame, rows[0], title, &app.palette); let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - frame.render_widget(Clear, input_rect); - frame.render_widget( - Paragraph::new(format!(" {}█", app.name_input)).style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0), - ), - input_rect, - ); + render_name_input_field(app, frame, input_rect); let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner); @@ -267,15 +294,7 @@ pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Fra rows[1], ); let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1); - frame.render_widget(Clear, input_rect); - frame.render_widget( - Paragraph::new(format!(" {}█", app.name_input)).style( - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0), - ), - input_rect, - ); + render_name_input_field(app, frame, input_rect); let checkout = create.checkout_path.display().to_string(); frame.render_widget( @@ -767,12 +786,19 @@ pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { #[cfg(test)] mod tests { use crate::{ - app::{state::WorktreeCreateState, AppState}, + app::{state::WorktreeCreateState, AppState, Mode}, workspace::Workspace, }; - use ratatui::{backend::TestBackend, layout::Rect, Terminal}; + use ratatui::{ + backend::TestBackend, + buffer::Buffer, + layout::{Position, Rect}, + Terminal, + }; - use super::{confirm_close_overlay_text, render_new_linked_worktree_overlay}; + use super::{ + confirm_close_overlay_text, render_new_linked_worktree_overlay, render_rename_overlay, + }; #[test] fn confirm_close_text_uses_live_workspace_cwd_label() { @@ -943,4 +969,190 @@ mod tests { assert_eq!(create.y, inner.y + inner.height - 1); assert_eq!(cancel.y, inner.y + inner.height - 1); } + + const RENAME_AREA: Rect = Rect { + x: 0, + y: 0, + width: 80, + height: 20, + }; + const WORKTREE_AREA: Rect = Rect { + x: 0, + y: 0, + width: 100, + height: 30, + }; + + /// Reproduces the input row that `render_rename_overlay` lays out: the + /// centred popup, the border inset, then the third row of the vertical + /// split. + fn rename_input_rect(area: Rect) -> Rect { + let popup = super::centered_popup_rect(area, 56, 7).expect("popup fits"); + let inner = Rect::new(popup.x + 1, popup.y + 1, popup.width - 2, popup.height - 2); + Rect::new(inner.x, inner.y + 2, inner.width, 1) + } + + fn rename_overlay_caret_in(mode: Mode, name: &str) -> (Position, Buffer) { + let mut app = AppState::test_new(); + app.mode = mode; + app.name_input = name.into(); + + let mut terminal = Terminal::new(TestBackend::new(RENAME_AREA.width, RENAME_AREA.height)) + .expect("test terminal"); + terminal + .draw(|frame| render_rename_overlay(&app, frame, RENAME_AREA)) + .expect("rename overlay should render"); + let caret = terminal.get_cursor_position().expect("cursor position"); + (caret, terminal.backend().buffer().clone()) + } + + fn rename_overlay_caret(name: &str) -> Position { + rename_overlay_caret_in(Mode::RenameWorkspace, name).0 + } + + fn worktree_overlay_caret(branch: &str) -> Position { + let mut app = AppState::test_new(); + app.name_input = branch.into(); + app.worktree_create = Some(WorktreeCreateState { + source_workspace_id: "source".into(), + source_checkout_path: "/repo/herdr".into(), + source_existing_membership: None, + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: branch.into(), + checkout_path: "/repo/.worktrees/herdr/foo".into(), + error: None, + creating: false, + }); + + let mut terminal = + Terminal::new(TestBackend::new(WORKTREE_AREA.width, WORKTREE_AREA.height)) + .expect("test terminal"); + terminal + .draw(|frame| render_new_linked_worktree_overlay(&app, frame, WORKTREE_AREA)) + .expect("new worktree overlay should render"); + terminal.get_cursor_position().expect("cursor position") + } + + #[test] + fn rename_overlay_anchors_the_host_cursor_to_the_input_caret() { + let input = rename_input_rect(RENAME_AREA); + + // Without an explicit cursor the frame carries none, the client parks the + // host cursor where the focused pane last reported it, and the IME + // composes there instead of in the dialog. + assert_eq!( + rename_overlay_caret(""), + Position::new(input.x + 1, input.y), + "empty input should put the caret past the one-column left padding" + ); + assert_eq!( + rename_overlay_caret("abcd"), + Position::new(input.x + 5, input.y) + ); + + // The cell under the caret has to be blank: a host terminal draws its + // cursor by inverting that cell, so a glyph there would swallow it. + let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, "ab"); + assert_eq!(caret, Position::new(input.x + 3, input.y)); + assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); + assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "b"); + } + + #[test] + fn rename_overlay_anchors_the_cursor_in_every_rename_mode() { + let input = rename_input_rect(RENAME_AREA); + let expected = Position::new(input.x + 3, input.y); + + for mode in [Mode::RenameWorkspace, Mode::RenameTab, Mode::RenamePane] { + assert_eq!( + rename_overlay_caret_in(mode, "ab").0, + expected, + "{mode:?} should anchor the caret like the other rename modes" + ); + } + } + + #[test] + fn rename_overlay_caret_counts_wide_characters_as_two_columns() { + let input = rename_input_rect(RENAME_AREA); + + // "あい" is two columns per character, so the caret sits two cells further + // right than the two-column "ab". + assert_eq!( + rename_overlay_caret("あい"), + Position::new(input.x + 5, input.y) + ); + assert_eq!( + rename_overlay_caret("aあ"), + Position::new(input.x + 4, input.y) + ); + } + + #[test] + fn rename_overlay_caret_stays_inside_the_input_when_the_name_overflows() { + let input = rename_input_rect(RENAME_AREA); + let last_column = input.right() - 1; + + // The field is 54 columns wide. 51 characters is the last name whose + // caret still lands strictly inside it; from 52 on the unclamped column + // would leave the field and gets pinned to the final cell. + assert_eq!( + rename_overlay_caret(&"a".repeat(51)), + Position::new(input.x + 52, input.y) + ); + assert_eq!( + rename_overlay_caret(&"a".repeat(53)), + Position::new(last_column, input.y) + ); + assert_eq!( + rename_overlay_caret(&"a".repeat(200)), + Position::new(last_column, input.y) + ); + + // The clamped cell has to stay blank as well, or the host cursor would + // sit on a glyph and the IME would compose over it. + let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, &"a".repeat(200)); + assert_eq!(caret, Position::new(last_column, input.y)); + assert_eq!(buffer[(caret.x, caret.y)].symbol(), " "); + assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "a"); + } + + #[test] + fn rename_overlay_caret_reaches_the_frame_the_server_sends() { + let input = rename_input_rect(RENAME_AREA); + let mut app = AppState::test_new(); + app.mode = Mode::RenameWorkspace; + app.name_input = "ab".into(); + + // The widget tests above stop at the ratatui frame. This one goes through + // the server's cursor resolution, which is where the bug lived: the frame + // used to leave here with `cursor: None`. + let (_, cursor) = + crate::server::render_stream::render_virtual(&mut app, RENAME_AREA, false); + let cursor = cursor.expect("the modal caret should survive cursor resolution"); + + assert_eq!((cursor.x, cursor.y), (input.x + 3, input.y)); + assert!(cursor.visible); + } + + #[test] + fn new_worktree_overlay_anchors_the_host_cursor_to_the_input_caret() { + let popup = super::new_linked_worktree_inner_rect(WORKTREE_AREA).expect("popup fits"); + let input = Rect::new(popup.x, popup.y + 2, popup.width, 1); + + assert_eq!( + worktree_overlay_caret(""), + Position::new(input.x + 1, input.y) + ); + assert_eq!( + worktree_overlay_caret("ab"), + Position::new(input.x + 3, input.y) + ); + assert_eq!( + worktree_overlay_caret("あい"), + Position::new(input.x + 5, input.y) + ); + } } |
