feat: show block cursors in active text inputs

This commit is contained in:
2026-10-10 00:01:11 +02:00
parent ce68e254e1
commit f7c8ef0f3c
+65 -9
View File
@@ -6,6 +6,8 @@
//! Search and new-tag text: Option/Alt+Backspace or Ctrl-W deletes the previous whitespace word. //! 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. //! 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. //! 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: 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. //! 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. //! 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 clap::Parser;
use ratatui::{ use ratatui::{
DefaultTerminal, Frame, 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}, layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize}, style::{Color, Style, Stylize},
text::Line, text::Line,
@@ -702,19 +708,28 @@ impl App {
}; };
// End-only editing; scroll long queries without counting UTF-8 bytes as columns. // End-only editing; scroll long queries without counting UTF-8 bytes as columns.
let query = ratatui::text::Line::raw(self.query.as_str()); let query = ratatui::text::Line::raw(self.query.as_str());
let scroll = query let query_width = query.width();
.width() let search_block = Block::bordered()
.saturating_sub(search.width.saturating_sub(3) as usize); .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( frame.render_widget(
Paragraph::new(query) Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16)) .scroll((0, scroll.min(u16::MAX as usize) as u16))
.block( .block(search_block),
Block::bordered()
.title(" tubular / YouTube search ")
.border_style(search_style),
),
search, 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 view = &mut self.views[self.view_index];
let table = Table::new( let table = Table::new(
view.videos.iter().map(|video| { view.videos.iter().map(|video| {
@@ -907,6 +922,14 @@ impl App {
} }
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { 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(); self.thumbnail.query_terminal();
if !self.query.trim().is_empty() { if !self.query.trim().is_empty() {
self.search(); 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] #[test]
fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() { fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() {
for tag_input in [false, true] { for tag_input in [false, true] {