Fix TUI search cursor editing

This commit is contained in:
michel
2026-04-26 11:43:44 +02:00
parent dfe6f532fa
commit 807dea5f17
2 changed files with 216 additions and 42 deletions
+2
View File
@@ -61,6 +61,7 @@ pub struct AppState {
pub should_exit: bool, pub should_exit: bool,
pub command_bar_active: bool, pub command_bar_active: bool,
pub command_bar_input: String, pub command_bar_input: String,
pub command_bar_cursor_position: usize,
// Pane areas for mouse interaction // Pane areas for mouse interaction
pub searches_pane_area: Option<Rect>, pub searches_pane_area: Option<Rect>,
@@ -102,6 +103,7 @@ impl AppState {
should_exit: false, should_exit: false,
command_bar_active: false, command_bar_active: false,
command_bar_input: String::new(), command_bar_input: String::new(),
command_bar_cursor_position: 0,
searches_pane_area: None, searches_pane_area: None,
results_pane_area: None, results_pane_area: None,
+214 -42
View File
@@ -13,10 +13,10 @@ use color_eyre::Result;
use ratatui::{ use ratatui::{
DefaultTerminal, Frame, DefaultTerminal, Frame,
crossterm::event::{ crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
MouseEventKind, poll, MouseButton, MouseEvent, MouseEventKind, poll,
}, },
layout::{Constraint, Layout}, layout::{Constraint, Layout, Position, Rect},
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
}; };
use soulseek_rs::{Client, DownloadStatus}; use soulseek_rs::{Client, DownloadStatus};
@@ -30,6 +30,8 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
const COMMAND_BAR_PREFIX: &str = "search: ";
pub struct MainTui { pub struct MainTui {
client: Arc<Client>, client: Arc<Client>,
state: AppState, state: AppState,
@@ -195,34 +197,50 @@ impl MainTui {
} }
} }
fn render_shortcuts(&self, frame: &mut Frame, area: ratatui::layout::Rect) { fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
let shortcuts = match self.state.focused_pane { let shortcuts = if self.state.command_bar_active {
FocusedPane::Searches => vec![ vec![
("s", "search"), ("Type", "search term"),
("1-3", "focus pane"), ("←→", "move cursor"),
("↑↓", "navigate"), ("Backspace/Del", "edit"),
("Enter", "view results"), ("Enter", "search"),
("d", "remove search"), ("Esc", "cancel"),
("C", "clear all"), ]
("q", "quit"), } else {
], match self.state.focused_pane {
FocusedPane::Results if self.state.results_is_filtering => vec![ FocusedPane::Searches => vec![
("Type", "filter"), ("s", "search"),
("Esc", "clear filter"), ("1-3", "focus pane"),
("1-3", "focus pane"), ("↑↓", "navigate"),
("q", "quit"), ("Enter", "view results"),
], ("d", "remove search"),
FocusedPane::Results => vec![ ("C", "clear all"),
("Space", "select"), ("q", "quit"),
("/", "filter"), ],
("a/A", "select all/none"), FocusedPane::Results if self.state.results_is_filtering => {
("Enter", "download"), vec![
("1-3", "focus pane"), ("Type", "filter"),
("↑↓", "navigate"), ("Esc", "clear filter"),
("q", "quit"), ("1-3", "focus pane"),
], ("q", "quit"),
FocusedPane::Downloads => { ]
vec![("1-3", "focus pane"), ("↑↓", "navigate"), ("q", "quit")] }
FocusedPane::Results => vec![
("Space", "select"),
("/", "filter"),
("a/A", "select all/none"),
("Enter", "download"),
("1-3", "focus pane"),
("↑↓", "navigate"),
("q", "quit"),
],
FocusedPane::Downloads => {
vec![
("1-3", "focus pane"),
("↑↓", "navigate"),
("q", "quit"),
]
}
} }
}; };
@@ -237,13 +255,16 @@ impl MainTui {
frame.render_widget(shortcuts_widget, area); frame.render_widget(shortcuts_widget, area);
} }
fn render_command_bar( fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
&self, let content_width = area.width.saturating_sub(2);
frame: &mut Frame, let prefix_width = COMMAND_BAR_PREFIX.chars().count() as u16;
area: ratatui::layout::Rect, let input_width = content_width.saturating_sub(prefix_width);
) { let (visible_input, cursor_column) = visible_input_at_cursor(
// Vim-style command bar with ":" prefix &self.state.command_bar_input,
let command_text = format!("search: {}", self.state.command_bar_input); self.state.command_bar_cursor_position,
input_width,
);
let command_text = format!("{COMMAND_BAR_PREFIX}{visible_input}");
let paragraph = Paragraph::new(command_text).block( let paragraph = Paragraph::new(command_text).block(
Block::default() Block::default()
@@ -253,6 +274,16 @@ impl MainTui {
); );
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
if area.width > 2 && area.height > 2 {
let cursor_x = area
.x
.saturating_add(1)
.saturating_add(prefix_width)
.saturating_add(cursor_column)
.min(area.x.saturating_add(area.width.saturating_sub(2)));
frame.set_cursor_position(Position::new(cursor_x, area.y + 1));
}
} }
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
@@ -289,6 +320,7 @@ impl MainTui {
KeyCode::Char('s') => { KeyCode::Char('s') => {
self.state.command_bar_active = true; self.state.command_bar_active = true;
self.state.command_bar_input.clear(); self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
return Ok(()); return Ok(());
} }
_ => {} _ => {}
@@ -303,6 +335,11 @@ impl MainTui {
} }
fn handle_command_bar_input(&mut self, key: KeyEvent) -> Result<()> { fn handle_command_bar_input(&mut self, key: KeyEvent) -> Result<()> {
self.state.command_bar_cursor_position = clamp_cursor_to_char_boundary(
&self.state.command_bar_input,
self.state.command_bar_cursor_position,
);
match key.code { match key.code {
KeyCode::Enter => { KeyCode::Enter => {
let query = self.state.command_bar_input.trim().to_string(); let query = self.state.command_bar_input.trim().to_string();
@@ -311,16 +348,87 @@ impl MainTui {
} }
self.state.command_bar_active = false; self.state.command_bar_active = false;
self.state.command_bar_input.clear(); self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
} }
KeyCode::Esc => { KeyCode::Esc => {
self.state.command_bar_active = false; self.state.command_bar_active = false;
self.state.command_bar_input.clear(); self.state.command_bar_input.clear();
} self.state.command_bar_cursor_position = 0;
KeyCode::Char(c) => {
self.state.command_bar_input.push(c);
} }
KeyCode::Backspace => { KeyCode::Backspace => {
self.state.command_bar_input.pop(); let cursor_position = self.state.command_bar_cursor_position;
if cursor_position > 0 {
let previous_position = previous_char_boundary(
&self.state.command_bar_input,
cursor_position,
);
self.state
.command_bar_input
.drain(previous_position..cursor_position);
self.state.command_bar_cursor_position = previous_position;
}
}
KeyCode::Delete => {
let cursor_position = self.state.command_bar_cursor_position;
if cursor_position < self.state.command_bar_input.len() {
let next_position = next_char_boundary(
&self.state.command_bar_input,
cursor_position,
);
self.state
.command_bar_input
.drain(cursor_position..next_position);
}
}
KeyCode::Left => {
self.state.command_bar_cursor_position = previous_char_boundary(
&self.state.command_bar_input,
self.state.command_bar_cursor_position,
);
}
KeyCode::Right => {
self.state.command_bar_cursor_position = next_char_boundary(
&self.state.command_bar_input,
self.state.command_bar_cursor_position,
);
}
KeyCode::Home => {
self.state.command_bar_cursor_position = 0;
}
KeyCode::End => {
self.state.command_bar_cursor_position =
self.state.command_bar_input.len();
}
KeyCode::Char('a')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.state.command_bar_cursor_position = 0;
}
KeyCode::Char('e')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.state.command_bar_cursor_position =
self.state.command_bar_input.len();
}
KeyCode::Char('u')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
}
KeyCode::Char(c)
if !key.modifiers.intersects(
KeyModifiers::CONTROL
| KeyModifiers::ALT
| KeyModifiers::SUPER
| KeyModifiers::HYPER
| KeyModifiers::META,
) =>
{
let cursor_position = self.state.command_bar_cursor_position;
self.state.command_bar_input.insert(cursor_position, c);
self.state.command_bar_cursor_position =
cursor_position + c.len_utf8();
} }
_ => {} _ => {}
} }
@@ -862,3 +970,67 @@ pub fn launch_main_tui(
); );
tui.run(terminal) tui.run(terminal)
} }
fn clamp_cursor_to_char_boundary(input: &str, cursor_position: usize) -> usize {
if cursor_position >= input.len() {
return input.len();
}
let mut cursor_position = cursor_position;
while cursor_position > 0 && !input.is_char_boundary(cursor_position) {
cursor_position -= 1;
}
cursor_position
}
fn previous_char_boundary(input: &str, cursor_position: usize) -> usize {
let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position);
input[..cursor_position]
.char_indices()
.last()
.map(|(index, _)| index)
.unwrap_or(0)
}
fn next_char_boundary(input: &str, cursor_position: usize) -> usize {
let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position);
if cursor_position >= input.len() {
return input.len();
}
cursor_position
+ input[cursor_position..]
.chars()
.next()
.map(char::len_utf8)
.unwrap_or(0)
}
fn visible_input_at_cursor(
input: &str,
cursor_position: usize,
width: u16,
) -> (String, u16) {
if width == 0 {
return (String::new(), 0);
}
let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position);
let cursor_character_index = input[..cursor_position].chars().count();
let max_cursor_column = usize::from(width.saturating_sub(1));
let start_character_index =
cursor_character_index.saturating_sub(max_cursor_column);
let visible_input = input
.chars()
.skip(start_character_index)
.take(usize::from(width))
.collect();
let cursor_column = cursor_character_index
.saturating_sub(start_character_index)
.min(max_cursor_column);
(visible_input, cursor_column as u16)
}