diff --git a/src/main.rs b/src/main.rs index f2efa76..0c860ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ //! Search and new-tag text: Option/Alt+Backspace or Ctrl-W deletes the previous whitespace word. //! Cmd/Super+Backspace or Ctrl-U clears the line; plain Backspace removes one character. //! The terminal must transmit modified Backspace; otherwise map it to Ctrl-W or Ctrl-U. +//! Active text fields show a steady block cursor at the scrolled end; other views hide it. +//! The terminal's configured default cursor shape is restored when the app exits. //! Normal: j/k selects, Enter streams, and i returns to insert mode to edit the query. //! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages. //! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells. @@ -37,7 +39,11 @@ use std::{collections::BTreeMap, ffi::OsString, io, path::PathBuf, time::Duratio use clap::Parser; use ratatui::{ DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + crossterm::{ + cursor::SetCursorStyle, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + execute, + }, layout::{Constraint, Layout, Rect}, style::{Color, Style, Stylize}, text::Line, @@ -702,19 +708,28 @@ impl App { }; // End-only editing; scroll long queries without counting UTF-8 bytes as columns. let query = ratatui::text::Line::raw(self.query.as_str()); - let scroll = query - .width() - .saturating_sub(search.width.saturating_sub(3) as usize); + let query_width = query.width(); + let search_block = Block::bordered() + .title(" tubular / YouTube search ") + .border_style(search_style); + let input = search_block.inner(search); + let scroll = query_width.saturating_sub(input.width.saturating_sub(1) as usize); frame.render_widget( Paragraph::new(query) .scroll((0, scroll.min(u16::MAX as usize) as u16)) - .block( - Block::bordered() - .title(" tubular / YouTube search ") - .border_style(search_style), - ), + .block(search_block), search, ); + if self.mode == Mode::Insert + && self.category_dialog.is_none() + && input.width != 0 + && input.height != 0 + { + let cursor = query_width + .saturating_sub(scroll) + .min(usize::from(input.width - 1)); + frame.set_cursor_position((input.x + cursor as u16, input.y)); + } let view = &mut self.views[self.view_index]; let table = Table::new( view.videos.iter().map(|video| { @@ -907,6 +922,14 @@ impl App { } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + struct ResetCursorStyle; + impl Drop for ResetCursorStyle { + fn drop(&mut self) { + let _ = execute!(io::stdout(), SetCursorStyle::DefaultUserShape); + } + } + execute!(io::stdout(), SetCursorStyle::SteadyBlock)?; + let _cursor_style = ResetCursorStyle; self.thumbnail.query_terminal(); if !self.query.trim().is_empty() { self.search(); @@ -991,6 +1014,39 @@ mod tests { ) } + #[test] + fn input_cursor_tracks_display_width_and_stays_inside_scrolled_fields() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = app(&["tubular"]); + let mut terminal = Terminal::new(TestBackend::new(24, 20)).unwrap(); + for (text, x) in [ + ("", 1), + ("ab", 3), + ("日本語", 7), + ("e\u{301}", 2), + ("abcdefghijklmnopqrstuvwxyz", 22), + ] { + app.query = text.into(); + terminal.draw(|frame| app.draw(frame)).unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), (x, 2).into()); + } + app.category_dialog = Some(CategoryDialog { + channel: String::new(), + handle: String::new(), + label: String::new(), + tags: BTreeMap::new(), + selection: ListState::default(), + name: Some(String::new()), + error: None, + }); + for (text, x) in [("", 3), ("日本語", 9), ("abcdefghijklmnopqrstuvwxyz", 20)] { + app.category_dialog.as_mut().unwrap().name = Some(text.into()); + terminal.draw(|frame| app.draw(frame)).unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), (x, 7).into()); + } + } + #[test] fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() { for tag_input in [false, true] {