Changes: - Non-blocking coding-data sharing upsell banner - Consolidate remediation in Doctor - Auto mode defers fail-closed gate asks to the classifier - Coalesce marketplace list fetches - Allow removing a marketplace source by name - Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal) - Label failed workspace RPCs with error_kind - Drop redundant explicit tonic/prost deps from xai-grok-shell - Report real exit codes for completed background shells - Narrow the date-rollover reminder to date-bearing templates - Wire toolOverrides through the session and agent - Security: Bash(git:*) allowlist matches whole command chain by prefix - Split prompt-trigger telemetry and record classifier provenance - Raise connectors-manager timeout to 60s - Auto classifier honors recorded approvals for repeat actions - Apply doctor fixes in the TUI - Auto-mode classifier timeouts prompt instead of silently denying - Scope subagent completion drains to the owning session - Add the toolOverrides wire types - Set client_identifier=grok-agent-sdk - Accept both spellings of the workspace-teleport kill switch - Persist one-shot occurrence journal - Stop turns that poll the exact same tool call 16x in a row - Copy compaction checkpoint files when forking sessions - Auto-focus permission prompt from scrollback - Esc cancels the running turn in non-vim and minimal modes - List Ctrl+Z undo and redo in keyboard shortcuts - Out-of-process macOS mic capture - Show active auth mode on session-info - Install the npm binary under $GROK_HOME - Remove hover/click dead zones between dashboard items - Route startup warnings to doctor - Document [feedback.user] author identity config - Extend bang command timeout - Close combine-queued edit-hold race - Integrate relocation recovery - Expose privacy notice rollout flag - Break harness discovery ref cycle so connections can idle-evict - Shift/Alt+Enter inserts newline when editing a queued prompt - Gate project Claude permissions on folder trust - Echo response.create.event_id on response.created - Toast when session creation fails from disk full - Add shared test process lifecycle - Enable dynamic workflows by default - Add relocation transaction state machine - Add shared test sandbox - Surface auth failures on model-switch compact - Persist durable scheduler expiry - Confirm before removing extensions-modal items - Re-run compact and prompt after login when compact hit expired auth - Recap sends hosted tools under backend search
1896 lines
66 KiB
Rust
1896 lines
66 KiB
Rust
//! Demo for TextArea with @-file-search completion and atomic text elements.
|
||
//!
|
||
//! Run with: cargo run -p xai-ratatui-textarea --example textarea_demo
|
||
//!
|
||
//! Features demonstrated:
|
||
//! - Type `@` to trigger fuzzy file search (real files from current directory)
|
||
//! - Tab or Enter confirms selection → creates an atomic text element
|
||
//! - Up/Down to navigate results, Esc to dismiss
|
||
//! - Bracketed paste → creates paste elements
|
||
//! - Elements render as styled chips, cursor skips over them atomically
|
||
//! - Display projection: cursor column accounts for display width, not buffer width
|
||
|
||
use std::collections::HashMap;
|
||
use std::io::{self, stdout};
|
||
use std::ops::{Range, RangeInclusive};
|
||
use std::time::Duration;
|
||
|
||
use crossterm::ExecutableCommand;
|
||
use crossterm::cursor::{EnableBlinking, SetCursorStyle};
|
||
use crossterm::event::{
|
||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event,
|
||
KeyCode, KeyEvent, KeyModifiers,
|
||
};
|
||
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||
use fuzzy_matcher::FuzzyMatcher;
|
||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::CrosstermBackend;
|
||
use ratatui::layout::{Constraint, Layout, Rect};
|
||
use ratatui::style::{Color, Style, Stylize};
|
||
use ratatui::text::{Line, Span, Text};
|
||
use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidgetRef, Widget};
|
||
|
||
use xai_ratatui_textarea::wrapping::{RtOptions, word_wrap_line};
|
||
use xai_ratatui_textarea::{
|
||
ClipboardProvider, ElementId, ElementKind, MouseAction, TextArea, TextAreaState, TextElement,
|
||
TextElementEventKind,
|
||
};
|
||
|
||
// ── Element kinds ──
|
||
|
||
const KIND_PASTE: ElementKind = ElementKind(1);
|
||
const KIND_FILE_REF: ElementKind = ElementKind(2);
|
||
|
||
/// Maximum number of file search results shown in the dropdown.
|
||
const MAX_RESULTS: usize = 8;
|
||
|
||
// ── System clipboard provider ──
|
||
|
||
/// Clipboard backed by `arboard` — copies/pastes to/from system clipboard.
|
||
#[derive(Debug)]
|
||
struct ArboardClipboard;
|
||
|
||
impl ClipboardProvider for ArboardClipboard {
|
||
fn get(&mut self) -> Option<String> {
|
||
arboard::Clipboard::new().ok()?.get_text().ok()
|
||
}
|
||
|
||
fn set(&mut self, text: &str) {
|
||
if let Ok(mut clip) = arboard::Clipboard::new() {
|
||
let _ = clip.set_text(text);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// File search
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
/// A single fuzzy-matched file result.
|
||
struct SearchResult {
|
||
path: String,
|
||
score: i64,
|
||
/// Character indices in `path` that matched the query (for highlighting).
|
||
indices: Vec<usize>,
|
||
}
|
||
|
||
/// Manages the file list, fuzzy matcher, and dropdown state for @-completion.
|
||
struct FileSearch {
|
||
all_files: Vec<String>,
|
||
matcher: SkimMatcherV2,
|
||
results: Vec<SearchResult>,
|
||
selected: usize,
|
||
}
|
||
|
||
/// Context extracted from textarea buffer describing an active @-completion trigger.
|
||
struct FileSearchContext {
|
||
/// Byte range in the buffer covering `@query` (includes the `@`).
|
||
range: Range<usize>,
|
||
/// The query text (characters after `@`, up to cursor position).
|
||
query: String,
|
||
}
|
||
|
||
impl FileSearch {
|
||
fn new() -> Self {
|
||
let all_files = collect_files();
|
||
Self {
|
||
all_files,
|
||
matcher: SkimMatcherV2::default(),
|
||
results: Vec::new(),
|
||
selected: 0,
|
||
}
|
||
}
|
||
|
||
/// Re-run fuzzy matching against `query` and update the results list.
|
||
fn update(&mut self, query: &str) {
|
||
self.results.clear();
|
||
if query.is_empty() {
|
||
// Show first N files alphabetically when query is empty.
|
||
for path in self.all_files.iter().take(MAX_RESULTS) {
|
||
self.results.push(SearchResult {
|
||
path: path.clone(),
|
||
score: 0,
|
||
indices: Vec::new(),
|
||
});
|
||
}
|
||
} else {
|
||
let mut scored: Vec<_> = self
|
||
.all_files
|
||
.iter()
|
||
.filter_map(|path| {
|
||
self.matcher
|
||
.fuzzy_indices(path, query)
|
||
.map(|(score, indices)| SearchResult {
|
||
path: path.clone(),
|
||
score,
|
||
indices,
|
||
})
|
||
})
|
||
.collect();
|
||
scored.sort_by(|a, b| b.score.cmp(&a.score));
|
||
scored.truncate(MAX_RESULTS);
|
||
self.results = scored;
|
||
}
|
||
self.clamp_selection();
|
||
}
|
||
|
||
fn move_selection(&mut self, delta: isize) {
|
||
if self.results.is_empty() {
|
||
return;
|
||
}
|
||
let max = self.results.len() as isize - 1;
|
||
self.selected = (self.selected as isize + delta).clamp(0, max) as usize;
|
||
}
|
||
|
||
fn selected_path(&self) -> Option<&str> {
|
||
self.results.get(self.selected).map(|r| r.path.as_str())
|
||
}
|
||
|
||
fn is_visible(&self) -> bool {
|
||
!self.results.is_empty()
|
||
}
|
||
|
||
fn clear(&mut self) {
|
||
self.results.clear();
|
||
self.selected = 0;
|
||
}
|
||
|
||
fn clamp_selection(&mut self) {
|
||
if self.results.is_empty() {
|
||
self.selected = 0;
|
||
} else {
|
||
self.selected = self.selected.min(self.results.len() - 1);
|
||
}
|
||
}
|
||
|
||
/// Height needed for the dropdown (0 when hidden).
|
||
fn dropdown_height(&self) -> u16 {
|
||
if self.results.is_empty() {
|
||
0
|
||
} else {
|
||
// results + 2 for the border
|
||
(self.results.len() as u16 + 2).min(MAX_RESULTS as u16 + 2)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Walk the current directory using the `ignore` crate (respects .gitignore).
|
||
fn collect_files() -> Vec<String> {
|
||
let mut files = Vec::new();
|
||
for entry in ignore::Walk::new(".") {
|
||
let Ok(entry) = entry else { continue };
|
||
if entry.file_type().is_none_or(|ft| !ft.is_file()) {
|
||
continue;
|
||
}
|
||
let path = entry.path().display().to_string();
|
||
let path = path.strip_prefix("./").unwrap_or(&path).to_string();
|
||
files.push(path);
|
||
}
|
||
files.sort();
|
||
files
|
||
}
|
||
|
||
/// Compute the @-completion context from current textarea state.
|
||
///
|
||
/// Scans backward from `cursor` looking for an `@` that could be a file search
|
||
/// trigger. Returns `None` if no valid context is found.
|
||
fn compute_file_search_context(
|
||
text: &str,
|
||
cursor: usize,
|
||
elements: &[TextElement],
|
||
) -> Option<FileSearchContext> {
|
||
if cursor == 0 {
|
||
return None;
|
||
}
|
||
|
||
let at_idx = text[..cursor].rfind('@')?;
|
||
|
||
// Don't trigger if the @ is inside an existing element (already confirmed).
|
||
if elements
|
||
.iter()
|
||
.any(|e| at_idx >= e.range.start && at_idx < e.range.end)
|
||
{
|
||
return None;
|
||
}
|
||
|
||
// Don't trigger if preceded by alphanumeric or _ (e.g. email-like `user@`).
|
||
if let Some(ch) = text[..at_idx].chars().next_back()
|
||
&& (ch.is_alphanumeric() || ch == '_')
|
||
{
|
||
return None;
|
||
}
|
||
|
||
// Find the end of the @-token (whitespace or punctuation terminates).
|
||
let token_end = text[at_idx + 1..]
|
||
.char_indices()
|
||
.find_map(|(offset, ch)| {
|
||
(ch.is_whitespace() || matches!(ch, ',' | ';')).then_some(at_idx + 1 + offset)
|
||
})
|
||
.unwrap_or(text.len());
|
||
|
||
// Cursor must be within the token.
|
||
if cursor > token_end {
|
||
return None;
|
||
}
|
||
|
||
let query = text[at_idx + 1..cursor].to_owned();
|
||
Some(FileSearchContext {
|
||
range: at_idx..token_end,
|
||
query,
|
||
})
|
||
}
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// Line select mode (file preview + line range picking)
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum SelectionState {
|
||
/// No selection active.
|
||
None,
|
||
/// First `v`: anchor line (0-indexed). Range extends as cursor moves.
|
||
Selecting(usize),
|
||
/// Second `v`: range locked (0-indexed, inclusive, sorted).
|
||
Locked(usize, usize),
|
||
}
|
||
|
||
/// Modal state for the file preview / line-range picker.
|
||
struct LineSelectMode {
|
||
file_path: String,
|
||
lines: Vec<String>,
|
||
cursor_line: usize, // 0-indexed
|
||
scroll_top: usize, // 0-indexed first visible line
|
||
viewport_height: usize,
|
||
goto_buf: String,
|
||
selection: SelectionState,
|
||
element_id: ElementId,
|
||
}
|
||
|
||
impl LineSelectMode {
|
||
/// Load a file and create a new line-select session.
|
||
fn open(file_path: String, element_id: ElementId) -> Option<Self> {
|
||
let content = std::fs::read_to_string(&file_path).ok()?;
|
||
let lines: Vec<String> = content.lines().map(String::from).collect();
|
||
if lines.is_empty() {
|
||
return None;
|
||
}
|
||
Some(Self {
|
||
file_path,
|
||
lines,
|
||
cursor_line: 0,
|
||
scroll_top: 0,
|
||
viewport_height: 20,
|
||
goto_buf: String::new(),
|
||
selection: SelectionState::None,
|
||
element_id,
|
||
})
|
||
}
|
||
|
||
fn total_lines(&self) -> usize {
|
||
self.lines.len()
|
||
}
|
||
|
||
fn move_cursor(&mut self, delta: isize) {
|
||
let max = self.total_lines().saturating_sub(1) as isize;
|
||
self.cursor_line = (self.cursor_line as isize + delta).clamp(0, max) as usize;
|
||
self.ensure_visible();
|
||
}
|
||
|
||
fn goto_line(&mut self, line_1indexed: usize) {
|
||
self.cursor_line = line_1indexed
|
||
.saturating_sub(1)
|
||
.min(self.total_lines().saturating_sub(1));
|
||
self.center_cursor();
|
||
}
|
||
|
||
fn ensure_visible(&mut self) {
|
||
if self.cursor_line < self.scroll_top {
|
||
self.scroll_top = self.cursor_line;
|
||
} else if self.cursor_line >= self.scroll_top + self.viewport_height {
|
||
self.scroll_top = self.cursor_line + 1 - self.viewport_height;
|
||
}
|
||
}
|
||
|
||
fn center_cursor(&mut self) {
|
||
let half = self.viewport_height / 2;
|
||
self.scroll_top = self.cursor_line.saturating_sub(half);
|
||
let max_scroll = self.total_lines().saturating_sub(self.viewport_height);
|
||
self.scroll_top = self.scroll_top.min(max_scroll);
|
||
}
|
||
|
||
fn toggle_selection(&mut self) -> SelectionState {
|
||
let prev = self.selection;
|
||
self.selection = match self.selection {
|
||
SelectionState::None => SelectionState::Selecting(self.cursor_line),
|
||
SelectionState::Selecting(anchor) => {
|
||
let (s, e) = sorted(anchor, self.cursor_line);
|
||
SelectionState::Locked(s, e)
|
||
}
|
||
SelectionState::Locked(_, _) => SelectionState::Selecting(self.cursor_line),
|
||
};
|
||
prev
|
||
}
|
||
|
||
/// Get the current effective line range (1-indexed, inclusive).
|
||
fn effective_range(&self) -> Option<RangeInclusive<usize>> {
|
||
match self.selection {
|
||
SelectionState::None => None,
|
||
SelectionState::Selecting(anchor) => {
|
||
let (s, e) = sorted(anchor, self.cursor_line);
|
||
Some((s + 1)..=(e + 1))
|
||
}
|
||
SelectionState::Locked(s, e) => Some((s + 1)..=(e + 1)),
|
||
}
|
||
}
|
||
|
||
/// Check if a 0-indexed line is in the current selection.
|
||
fn is_selected(&self, line: usize) -> bool {
|
||
match self.selection {
|
||
SelectionState::None => false,
|
||
SelectionState::Selecting(anchor) => {
|
||
let (s, e) = sorted(anchor, self.cursor_line);
|
||
line >= s && line <= e
|
||
}
|
||
SelectionState::Locked(s, e) => line >= s && line <= e,
|
||
}
|
||
}
|
||
|
||
/// If the selection covers every line, clear it (whole file = no range).
|
||
fn check_select_all(&mut self) {
|
||
let total = self.total_lines();
|
||
let covers_all = match self.selection {
|
||
SelectionState::Selecting(anchor) => {
|
||
let (s, e) = sorted(anchor, self.cursor_line);
|
||
s == 0 && e + 1 >= total
|
||
}
|
||
SelectionState::Locked(s, e) => s == 0 && e + 1 >= total,
|
||
SelectionState::None => false,
|
||
};
|
||
if covers_all {
|
||
self.selection = SelectionState::None;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn sorted(a: usize, b: usize) -> (usize, usize) {
|
||
if a <= b { (a, b) } else { (b, a) }
|
||
}
|
||
|
||
// ── File-ref helpers ──
|
||
|
||
/// Parse element text like `@foo.rs:123-456` into (path, optional range).
|
||
fn parse_file_ref(element_text: &str) -> (&str, Option<RangeInclusive<usize>>) {
|
||
let text = element_text.strip_prefix('@').unwrap_or(element_text);
|
||
if let Some(colon) = text.rfind(':') {
|
||
let suffix = &text[colon + 1..];
|
||
if let Some(range) = parse_line_range_str(suffix) {
|
||
return (&text[..colon], Some(range));
|
||
}
|
||
}
|
||
(text, None)
|
||
}
|
||
|
||
fn parse_line_range_str(s: &str) -> Option<RangeInclusive<usize>> {
|
||
if let Some(dash) = s.find('-') {
|
||
let start: usize = s[..dash].parse().ok()?;
|
||
let end: usize = s[dash + 1..].parse().ok()?;
|
||
Some(start..=end)
|
||
} else {
|
||
let line: usize = s.parse().ok()?;
|
||
Some(line..=line)
|
||
}
|
||
}
|
||
|
||
fn build_file_ref_text(path: &str, range: Option<&RangeInclusive<usize>>) -> String {
|
||
match range {
|
||
None => format!("@{path}"),
|
||
Some(r) if r.start() == r.end() => format!("@{path}:{}", r.start()),
|
||
Some(r) => format!("@{path}:{}-{}", r.start(), r.end()),
|
||
}
|
||
}
|
||
|
||
fn build_file_ref_display(path: &str, range: Option<&RangeInclusive<usize>>) -> Line<'static> {
|
||
let bg = Color::Rgb(30, 50, 30);
|
||
let mut spans = vec![
|
||
Span::styled("@", Style::default().fg(Color::Green).bg(bg).bold()),
|
||
Span::styled(path.to_string(), Style::default().fg(Color::Green).bg(bg)),
|
||
];
|
||
if let Some(r) = range {
|
||
let range_text = if r.start() == r.end() {
|
||
format!(":{}", r.start())
|
||
} else {
|
||
format!(":{}‑{}", r.start(), r.end())
|
||
};
|
||
spans.push(Span::styled(
|
||
range_text,
|
||
Style::default().fg(Color::Rgb(230, 120, 100)).bg(bg),
|
||
));
|
||
}
|
||
Line::from(spans)
|
||
}
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// App
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
/// Result of processing an input event.
|
||
enum EventResult {
|
||
/// Continue running, redraw the UI.
|
||
Redraw,
|
||
/// Continue running, nothing changed — skip redraw (lets cursor blink).
|
||
Unchanged,
|
||
/// Exit the application.
|
||
Quit,
|
||
}
|
||
|
||
/// Host-side metadata for an element.
|
||
struct ElementMeta {
|
||
description: String,
|
||
}
|
||
|
||
struct DemoApp {
|
||
textarea: TextArea,
|
||
textarea_state: TextAreaState,
|
||
element_meta: HashMap<ElementId, ElementMeta>,
|
||
status: String,
|
||
file_search: FileSearch,
|
||
/// Whether the file-search dropdown is logically active.
|
||
fs_active: bool,
|
||
/// Modal line-select / file-preview mode.
|
||
line_select: Option<LineSelectMode>,
|
||
/// Last render area for the textarea (needed for mouse→buffer mapping).
|
||
textarea_area: Rect,
|
||
}
|
||
|
||
impl DemoApp {
|
||
fn new() -> Self {
|
||
let file_search = FileSearch::new();
|
||
let file_count = file_search.all_files.len();
|
||
let mut textarea = TextArea::new();
|
||
textarea.set_clipboard_provider(Box::new(ArboardClipboard));
|
||
Self {
|
||
textarea,
|
||
textarea_state: TextAreaState::default(),
|
||
element_meta: HashMap::new(),
|
||
status: format!(
|
||
"Type text. @ to search {file_count} files. Tab/Enter confirm. Esc quit."
|
||
),
|
||
file_search,
|
||
fs_active: false,
|
||
line_select: None,
|
||
textarea_area: Rect::default(),
|
||
}
|
||
}
|
||
|
||
// ── Event handling ──
|
||
|
||
fn handle_event(&mut self, event: Event) -> EventResult {
|
||
match event {
|
||
Event::Paste(text) => {
|
||
self.handle_paste(&text);
|
||
self.recompute_file_search();
|
||
EventResult::Redraw
|
||
}
|
||
Event::Key(key) => self.handle_key(key),
|
||
Event::Mouse(mouse) => self.handle_mouse(mouse),
|
||
Event::Resize(_, _) => EventResult::Redraw,
|
||
_ => EventResult::Unchanged,
|
||
}
|
||
}
|
||
|
||
fn handle_paste(&mut self, text: &str) {
|
||
let has_newline = text.contains('\n');
|
||
|
||
if !has_newline {
|
||
// Single-line paste → insert inline as plain text (single undo step).
|
||
self.textarea.insert_str(text);
|
||
let char_count = text.chars().count();
|
||
self.status = format!("Pasted {char_count} chars inline");
|
||
return;
|
||
}
|
||
|
||
// Multi-line paste → create an element with summary display.
|
||
let line_count = text.lines().count();
|
||
|
||
let bg = Color::Rgb(40, 40, 50);
|
||
let display = Line::from(vec![
|
||
Span::styled("[", Style::default().fg(Color::DarkGray).bg(bg)),
|
||
Span::styled(
|
||
format!(
|
||
"Pasted {} line{}",
|
||
line_count,
|
||
if line_count != 1 { "s" } else { "" },
|
||
),
|
||
Style::default().fg(Color::Rgb(150, 150, 170)).bg(bg),
|
||
),
|
||
Span::styled("]", Style::default().fg(Color::DarkGray).bg(bg)),
|
||
]);
|
||
|
||
let id = self
|
||
.textarea
|
||
.insert_element(text, KIND_PASTE, Some(display));
|
||
|
||
self.element_meta.insert(
|
||
id,
|
||
ElementMeta {
|
||
description: format!(
|
||
"Pasted {} line{}",
|
||
line_count,
|
||
if line_count != 1 { "s" } else { "" },
|
||
),
|
||
},
|
||
);
|
||
|
||
self.status = format!(
|
||
"Pasted {} line{} (i to inline)",
|
||
line_count,
|
||
if line_count != 1 { "s" } else { "" },
|
||
);
|
||
}
|
||
|
||
fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) -> EventResult {
|
||
// Don't forward mouse events during line-select mode.
|
||
if self.line_select.is_some() {
|
||
return EventResult::Unchanged;
|
||
}
|
||
|
||
// Middle-click: paste from system clipboard at the click position.
|
||
if matches!(
|
||
mouse.kind,
|
||
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Middle)
|
||
) {
|
||
if let Ok(mut clip) = arboard::Clipboard::new()
|
||
&& let Ok(text) = clip.get_text()
|
||
{
|
||
// Place cursor at the click position first.
|
||
self.textarea
|
||
.handle_mouse(mouse, self.textarea_area, self.textarea_state);
|
||
self.handle_paste(&text);
|
||
return EventResult::Redraw;
|
||
}
|
||
return EventResult::Unchanged;
|
||
}
|
||
|
||
// If the click lands on the "❯ " prompt char (2 cols left of textarea),
|
||
// remap it to column 0 of the textarea so it places the cursor at the
|
||
// start of that visual line.
|
||
let mut mouse = mouse;
|
||
let ta = self.textarea_area;
|
||
if ta.width > 0
|
||
&& mouse.column >= ta.x.saturating_sub(2)
|
||
&& mouse.column < ta.x
|
||
&& mouse.row >= ta.y
|
||
&& mouse.row < ta.y + ta.height
|
||
{
|
||
mouse.column = ta.x;
|
||
}
|
||
|
||
let action = self
|
||
.textarea
|
||
.handle_mouse(mouse, self.textarea_area, self.textarea_state);
|
||
|
||
// Check for element interactions (click, hover enter/leave).
|
||
let mut had_element_event = false;
|
||
if let Some(elem_event) = self.textarea.poll_element_event() {
|
||
had_element_event = true;
|
||
match elem_event.kind {
|
||
TextElementEventKind::Click => {
|
||
if let Some(meta) = self.element_meta.get(&elem_event.id) {
|
||
self.status =
|
||
format!("Clicked element: {} (: to select lines)", meta.description);
|
||
} else {
|
||
self.status = format!("Clicked element {:?}", elem_event.id);
|
||
}
|
||
}
|
||
TextElementEventKind::HoverEnter => {
|
||
if let Some(meta) = self.element_meta.get(&elem_event.id) {
|
||
self.status = format!("Hovering: {}", meta.description);
|
||
}
|
||
}
|
||
TextElementEventKind::HoverLeave => {
|
||
if let Some(meta) = self.element_meta.get(&elem_event.id) {
|
||
self.status = format!("Left element: {}", meta.description);
|
||
} else {
|
||
self.status = format!("Left element {:?}", elem_event.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
match action {
|
||
MouseAction::CursorPlaced if !had_element_event => {
|
||
let pos = self.textarea.cursor();
|
||
self.status = format!("Click → cursor at byte {pos}");
|
||
self.recompute_file_search();
|
||
EventResult::Redraw
|
||
}
|
||
MouseAction::CursorPlaced => {
|
||
// Element click already set the status — don't overwrite.
|
||
self.recompute_file_search();
|
||
EventResult::Redraw
|
||
}
|
||
MouseAction::SelectionUpdated => {
|
||
if let Some(text) = self.textarea.selected_text() {
|
||
let chars = text.chars().count();
|
||
self.status = format!("Selecting… ({chars} chars)");
|
||
}
|
||
EventResult::Redraw
|
||
}
|
||
MouseAction::SelectionFinished => {
|
||
if let Some(text) = self.textarea.take_clipboard() {
|
||
let chars = text.chars().count();
|
||
self.status = format!("Selected {chars} chars (copied to clipboard)");
|
||
}
|
||
EventResult::Redraw
|
||
}
|
||
MouseAction::Nothing if had_element_event => EventResult::Redraw,
|
||
MouseAction::Nothing => EventResult::Unchanged,
|
||
MouseAction::Scrolled => EventResult::Redraw,
|
||
}
|
||
}
|
||
|
||
/// Confirm the currently-selected file search result.
|
||
///
|
||
/// Replaces the `@query` text with an atomic element and inserts a trailing space.
|
||
fn confirm_file_search(&mut self) {
|
||
let Some(ctx) = compute_file_search_context(
|
||
self.textarea.text(),
|
||
self.textarea.cursor(),
|
||
self.textarea.elements(),
|
||
) else {
|
||
return;
|
||
};
|
||
let Some(path) = self.file_search.selected_path() else {
|
||
return;
|
||
};
|
||
let path = path.to_owned();
|
||
|
||
let element_text = build_file_ref_text(&path, None);
|
||
let display = build_file_ref_display(&path, None);
|
||
|
||
// Group: replace + trailing space = 1 undo step.
|
||
self.textarea.begin_undo_group();
|
||
|
||
let id = self.textarea.replace_range_with_element(
|
||
ctx.range,
|
||
&element_text,
|
||
KIND_FILE_REF,
|
||
Some(display),
|
||
);
|
||
|
||
// Insert trailing space so the user can keep typing.
|
||
self.textarea.insert_str(" ");
|
||
|
||
self.textarea.end_undo_group();
|
||
|
||
self.element_meta.insert(
|
||
id,
|
||
ElementMeta {
|
||
description: format!("File: {path}"),
|
||
},
|
||
);
|
||
|
||
self.fs_active = false;
|
||
self.file_search.clear();
|
||
self.status = format!("Confirmed: @{path}");
|
||
}
|
||
|
||
/// Recompute file search context from current textarea state.
|
||
fn recompute_file_search(&mut self) {
|
||
let ctx = compute_file_search_context(
|
||
self.textarea.text(),
|
||
self.textarea.cursor(),
|
||
self.textarea.elements(),
|
||
);
|
||
match ctx {
|
||
Some(ctx) => {
|
||
self.file_search.update(&ctx.query);
|
||
self.fs_active = true;
|
||
if self.file_search.is_visible() {
|
||
self.status = format!(
|
||
"@-search: \"{}\" ({} match{})",
|
||
ctx.query,
|
||
self.file_search.results.len(),
|
||
if self.file_search.results.len() != 1 {
|
||
"es"
|
||
} else {
|
||
""
|
||
},
|
||
);
|
||
}
|
||
}
|
||
None => {
|
||
self.fs_active = false;
|
||
self.file_search.clear();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn handle_key(&mut self, key: KeyEvent) -> EventResult {
|
||
// ── Line select mode takes ALL keys ──
|
||
if self.line_select.is_some() {
|
||
return self.handle_line_select_key(key);
|
||
}
|
||
|
||
// ── Global quit / clear ──
|
||
match key {
|
||
KeyEvent {
|
||
code: KeyCode::Esc, ..
|
||
} => {
|
||
if self.fs_active {
|
||
self.fs_active = false;
|
||
self.file_search.clear();
|
||
self.status = "File search dismissed.".into();
|
||
return EventResult::Redraw;
|
||
}
|
||
return EventResult::Quit;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('q'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => return EventResult::Quit,
|
||
KeyEvent {
|
||
code: KeyCode::Char('c'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if self.textarea.is_empty() {
|
||
return EventResult::Quit;
|
||
}
|
||
self.textarea.set_text("");
|
||
self.element_meta.clear();
|
||
self.fs_active = false;
|
||
self.file_search.clear();
|
||
self.status = "Cleared.".into();
|
||
return EventResult::Redraw;
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
// ── File search key interception (when dropdown is visible) ──
|
||
if self.fs_active && self.file_search.is_visible() {
|
||
match key {
|
||
// ':' during file search → confirm file + open line select
|
||
KeyEvent {
|
||
code: KeyCode::Char(':'),
|
||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||
..
|
||
} => {
|
||
self.enter_line_select_from_search();
|
||
return EventResult::Redraw;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Tab, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Enter,
|
||
..
|
||
} => {
|
||
self.confirm_file_search();
|
||
return EventResult::Redraw;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Up, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('p'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.file_search.move_selection(-1);
|
||
return EventResult::Redraw;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Down,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('n'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
self.file_search.move_selection(1);
|
||
return EventResult::Redraw;
|
||
}
|
||
_ => {} // fall through to textarea
|
||
}
|
||
}
|
||
|
||
// ── ':' / Tab / Enter when cursor is on a file-ref element → open line select ──
|
||
if matches!(
|
||
key,
|
||
KeyEvent {
|
||
code: KeyCode::Char(':'),
|
||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||
..
|
||
} | KeyEvent {
|
||
code: KeyCode::Tab | KeyCode::Enter,
|
||
..
|
||
}
|
||
) && let Some(elem) = self.textarea.element_at_cursor()
|
||
&& elem.kind == KIND_FILE_REF
|
||
{
|
||
self.enter_line_select_from_element();
|
||
return EventResult::Redraw;
|
||
}
|
||
|
||
// ── 'i' on any element → inline it; Tab/Enter on paste element → inline ──
|
||
if let Some(elem) = self.textarea.element_at_cursor() {
|
||
let is_i = matches!(
|
||
key,
|
||
KeyEvent {
|
||
code: KeyCode::Char('i'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
}
|
||
);
|
||
let is_tab_enter = matches!(
|
||
key,
|
||
KeyEvent {
|
||
code: KeyCode::Tab | KeyCode::Enter,
|
||
..
|
||
}
|
||
);
|
||
if is_i || (is_tab_enter && elem.kind == KIND_PASTE) {
|
||
let id = elem.id;
|
||
let desc = self
|
||
.element_meta
|
||
.remove(&id)
|
||
.map(|m| m.description)
|
||
.unwrap_or_else(|| "element".into());
|
||
self.textarea.inline_element(id);
|
||
self.status = format!("Inlined {desc}");
|
||
self.recompute_file_search();
|
||
return EventResult::Redraw;
|
||
}
|
||
}
|
||
|
||
// ── Pass key to textarea, then recompute file search ──
|
||
|
||
// Undo / Redo — intercept before passing to textarea.input().
|
||
match key {
|
||
KeyEvent {
|
||
code: KeyCode::Char('z'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if self.textarea.undo() {
|
||
self.status = "Undo.".into();
|
||
} else {
|
||
self.status = "Nothing to undo.".into();
|
||
}
|
||
self.recompute_file_search();
|
||
return EventResult::Redraw;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('z'),
|
||
modifiers,
|
||
..
|
||
} if modifiers.contains(KeyModifiers::CONTROL)
|
||
&& modifiers.contains(KeyModifiers::SHIFT) =>
|
||
{
|
||
if self.textarea.redo() {
|
||
self.status = "Redo.".into();
|
||
} else {
|
||
self.status = "Nothing to redo.".into();
|
||
}
|
||
self.recompute_file_search();
|
||
return EventResult::Redraw;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('Z'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if self.textarea.redo() {
|
||
self.status = "Redo.".into();
|
||
} else {
|
||
self.status = "Nothing to redo.".into();
|
||
}
|
||
self.recompute_file_search();
|
||
return EventResult::Redraw;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('y'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
if self.textarea.redo() {
|
||
self.status = "Redo.".into();
|
||
} else {
|
||
self.status = "Nothing to redo.".into();
|
||
}
|
||
self.recompute_file_search();
|
||
return EventResult::Redraw;
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
self.textarea.input(key);
|
||
self.recompute_file_search();
|
||
|
||
// Update status when file search is not active.
|
||
if !self.fs_active {
|
||
if let Some(elem) = self.textarea.element_at_cursor() {
|
||
let id = elem.id;
|
||
if let Some(meta) = self.element_meta.get(&id) {
|
||
self.status = format!(
|
||
"Cursor on element: {} (: to select lines)",
|
||
meta.description
|
||
);
|
||
}
|
||
} else {
|
||
let elems = self.textarea.elements().len();
|
||
let chars = self.textarea.text().len();
|
||
self.status = format!(
|
||
"cursor: {} | {} chars | {} element{}",
|
||
self.textarea.cursor(),
|
||
chars,
|
||
elems,
|
||
if elems != 1 { "s" } else { "" },
|
||
);
|
||
}
|
||
}
|
||
|
||
EventResult::Redraw
|
||
}
|
||
|
||
// ── Line select entry / handling / confirm ──
|
||
|
||
fn enter_line_select_from_search(&mut self) {
|
||
// First, confirm the file search (create element).
|
||
let Some(ctx) = compute_file_search_context(
|
||
self.textarea.text(),
|
||
self.textarea.cursor(),
|
||
self.textarea.elements(),
|
||
) else {
|
||
return;
|
||
};
|
||
let Some(path) = self.file_search.selected_path() else {
|
||
return;
|
||
};
|
||
let path = path.to_owned();
|
||
|
||
let element_text = build_file_ref_text(&path, None);
|
||
let display = build_file_ref_display(&path, None);
|
||
|
||
// Begin undo group — stays open until confirm/cancel line select.
|
||
self.textarea.begin_undo_group();
|
||
|
||
let id = self.textarea.replace_range_with_element(
|
||
ctx.range,
|
||
&element_text,
|
||
KIND_FILE_REF,
|
||
Some(display),
|
||
);
|
||
self.textarea.insert_str(" ");
|
||
|
||
self.element_meta.insert(
|
||
id,
|
||
ElementMeta {
|
||
description: format!("File: {path}"),
|
||
},
|
||
);
|
||
self.fs_active = false;
|
||
self.file_search.clear();
|
||
|
||
// Now open line select.
|
||
if let Some(mode) = LineSelectMode::open(path.clone(), id) {
|
||
self.status =
|
||
format!("{path} | j/k ↕ | C-u/C-d ½pg | f/b pg | v sel | Enter ok | Esc cancel");
|
||
self.line_select = Some(mode);
|
||
} else {
|
||
// File unreadable — close the group immediately.
|
||
self.textarea.end_undo_group();
|
||
self.status = format!("Could not read: {path}");
|
||
}
|
||
}
|
||
|
||
fn enter_line_select_from_element(&mut self) {
|
||
let Some(elem) = self.textarea.element_at_cursor() else {
|
||
return;
|
||
};
|
||
if elem.kind != KIND_FILE_REF {
|
||
return;
|
||
}
|
||
let id = elem.id;
|
||
let elem_text = self.textarea.element_text(id).unwrap_or("").to_string();
|
||
let (path, existing_range) = parse_file_ref(&elem_text);
|
||
let path = path.to_string();
|
||
|
||
let Some(mut mode) = LineSelectMode::open(path.clone(), id) else {
|
||
self.status = format!("Could not read: {path}");
|
||
return;
|
||
};
|
||
|
||
// Begin undo group — stays open until confirm/cancel.
|
||
self.textarea.begin_undo_group();
|
||
|
||
// If there's an existing line range, scroll to it and show as locked.
|
||
if let Some(range) = existing_range {
|
||
let mid = (range.start() + range.end()) / 2;
|
||
mode.goto_line(mid);
|
||
mode.selection = SelectionState::Locked(
|
||
range.start().saturating_sub(1),
|
||
range.end().saturating_sub(1),
|
||
);
|
||
}
|
||
|
||
self.status =
|
||
format!("{path} | j/k ↕ | C-u/C-d ½pg | f/b pg | v sel | Enter ok | Esc cancel");
|
||
self.line_select = Some(mode);
|
||
}
|
||
|
||
fn handle_line_select_key(&mut self, key: KeyEvent) -> EventResult {
|
||
// Post-action to execute after the borrow of self.line_select is released.
|
||
enum Action {
|
||
Noop,
|
||
Cancel,
|
||
Confirm,
|
||
LiveUpdate(Option<RangeInclusive<usize>>),
|
||
}
|
||
|
||
let action = {
|
||
let Some(mode) = self.line_select.as_mut() else {
|
||
return EventResult::Redraw;
|
||
};
|
||
|
||
match key {
|
||
// Cancel: Esc, q, Ctrl-C
|
||
KeyEvent {
|
||
code: KeyCode::Esc, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('q'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('c'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => Action::Cancel,
|
||
|
||
// Confirm: Enter
|
||
KeyEvent {
|
||
code: KeyCode::Enter,
|
||
..
|
||
} => Action::Confirm,
|
||
|
||
// v / V (Shift-V): toggle selection
|
||
KeyEvent {
|
||
code: KeyCode::Char('v' | 'V'),
|
||
..
|
||
} => {
|
||
mode.toggle_selection();
|
||
// Only auto-clear on lock (second v), not while still selecting.
|
||
if matches!(mode.selection, SelectionState::Locked(..)) {
|
||
mode.check_select_all();
|
||
}
|
||
Action::LiveUpdate(mode.effective_range())
|
||
}
|
||
|
||
// g: first line
|
||
KeyEvent {
|
||
code: KeyCode::Char('g'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
mode.goto_line(1);
|
||
Action::Noop
|
||
}
|
||
// G: last line
|
||
KeyEvent {
|
||
code: KeyCode::Char('G'),
|
||
..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
mode.goto_line(mode.total_lines());
|
||
Action::Noop
|
||
}
|
||
|
||
// j / Down: down 1
|
||
KeyEvent {
|
||
code: KeyCode::Char('j'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Down,
|
||
..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
mode.move_cursor(1);
|
||
Action::Noop
|
||
}
|
||
// k / Up: up 1
|
||
KeyEvent {
|
||
code: KeyCode::Char('k'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Up, ..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
mode.move_cursor(-1);
|
||
Action::Noop
|
||
}
|
||
// Ctrl-D: half page down
|
||
KeyEvent {
|
||
code: KeyCode::Char('d'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
let half = (mode.viewport_height / 2).max(1) as isize;
|
||
mode.move_cursor(half);
|
||
Action::Noop
|
||
}
|
||
// Ctrl-U: half page up
|
||
KeyEvent {
|
||
code: KeyCode::Char('u'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
let half = (mode.viewport_height / 2).max(1) as isize;
|
||
mode.move_cursor(-half);
|
||
Action::Noop
|
||
}
|
||
// f: full page down
|
||
KeyEvent {
|
||
code: KeyCode::Char('f'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
let page = mode.viewport_height.max(1) as isize;
|
||
mode.move_cursor(page);
|
||
Action::Noop
|
||
}
|
||
// b: full page up
|
||
KeyEvent {
|
||
code: KeyCode::Char('b'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
mode.goto_buf.clear();
|
||
let page = mode.viewport_height.max(1) as isize;
|
||
mode.move_cursor(-page);
|
||
Action::Noop
|
||
}
|
||
// Digits: accumulate goto-line input and jump immediately
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} if c.is_ascii_digit() => {
|
||
mode.goto_buf.push(c);
|
||
if let Ok(line) = mode.goto_buf.parse::<usize>() {
|
||
mode.goto_line(line);
|
||
}
|
||
Action::Noop
|
||
}
|
||
// Any other key clears goto buffer
|
||
_ => {
|
||
mode.goto_buf.clear();
|
||
Action::Noop
|
||
}
|
||
}
|
||
};
|
||
|
||
// Execute action (line_select borrow is released).
|
||
match action {
|
||
Action::Cancel => {
|
||
self.cancel_line_select();
|
||
return EventResult::Redraw;
|
||
}
|
||
Action::Confirm => {
|
||
self.confirm_line_select();
|
||
return EventResult::Redraw;
|
||
}
|
||
Action::LiveUpdate(range) => {
|
||
self.update_line_select_element(range.as_ref());
|
||
}
|
||
Action::Noop => {
|
||
// Live-update element while actively selecting (range follows cursor).
|
||
let selecting_range = self.line_select.as_ref().and_then(|m| {
|
||
if matches!(m.selection, SelectionState::Selecting(_)) {
|
||
m.effective_range()
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
if let Some(range) = selecting_range {
|
||
self.update_line_select_element(Some(&range));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update status.
|
||
self.update_line_select_status();
|
||
EventResult::Redraw
|
||
}
|
||
|
||
fn cancel_line_select(&mut self) {
|
||
let Some(_mode) = self.line_select.take() else {
|
||
return;
|
||
};
|
||
|
||
// Cancel the undo group — restores textarea to pre-line-select state.
|
||
// No manual element revert needed.
|
||
self.textarea.cancel_undo_group();
|
||
|
||
self.status = "Line select cancelled.".into();
|
||
}
|
||
|
||
fn confirm_line_select(&mut self) {
|
||
let Some(mode) = self.line_select.take() else {
|
||
return;
|
||
};
|
||
|
||
let mut range = mode.effective_range();
|
||
// Selecting all lines = entire file = no range needed.
|
||
if let Some(ref r) = range
|
||
&& *r.start() == 1
|
||
&& *r.end() == mode.total_lines()
|
||
{
|
||
range = None;
|
||
}
|
||
|
||
let new_text = build_file_ref_text(&mode.file_path, range.as_ref());
|
||
let new_display = build_file_ref_display(&mode.file_path, range.as_ref());
|
||
|
||
// Find the element's current range in the buffer.
|
||
let elem_range = self
|
||
.textarea
|
||
.elements()
|
||
.iter()
|
||
.find(|e| e.id == mode.element_id)
|
||
.map(|e| e.range.clone());
|
||
|
||
if let Some(elem_range) = elem_range {
|
||
let new_id = self.textarea.replace_range_with_element(
|
||
elem_range,
|
||
&new_text,
|
||
KIND_FILE_REF,
|
||
Some(new_display),
|
||
);
|
||
|
||
let desc = match &range {
|
||
Some(r) if r.start() == r.end() => {
|
||
format!("File: {}:{}", mode.file_path, r.start())
|
||
}
|
||
Some(r) => format!("File: {}:{}-{}", mode.file_path, r.start(), r.end()),
|
||
None => format!("File: {}", mode.file_path),
|
||
};
|
||
self.element_meta.insert(
|
||
new_id,
|
||
ElementMeta {
|
||
description: desc.clone(),
|
||
},
|
||
);
|
||
self.status = format!("Confirmed: {desc}");
|
||
}
|
||
|
||
// Close the undo group — all line-select mutations become 1 undo step.
|
||
self.textarea.end_undo_group();
|
||
}
|
||
|
||
/// Live-update the element text/display to reflect a line range change.
|
||
fn update_line_select_element(&mut self, range: Option<&RangeInclusive<usize>>) {
|
||
// Extract data before mutating.
|
||
let (file_path, old_element_id) = {
|
||
let mode = self.line_select.as_ref().unwrap();
|
||
(mode.file_path.clone(), mode.element_id)
|
||
};
|
||
|
||
let elem_range = self
|
||
.textarea
|
||
.elements()
|
||
.iter()
|
||
.find(|e| e.id == old_element_id)
|
||
.map(|e| e.range.clone());
|
||
let Some(elem_range) = elem_range else {
|
||
return;
|
||
};
|
||
|
||
let new_text = build_file_ref_text(&file_path, range);
|
||
let new_display = build_file_ref_display(&file_path, range);
|
||
let new_id = self.textarea.replace_range_with_element(
|
||
elem_range,
|
||
&new_text,
|
||
KIND_FILE_REF,
|
||
Some(new_display),
|
||
);
|
||
|
||
let desc = match range {
|
||
Some(r) if r.start() == r.end() => format!("File: {}:{}", file_path, r.start()),
|
||
Some(r) => format!("File: {}:{}-{}", file_path, r.start(), r.end()),
|
||
None => format!("File: {}", file_path),
|
||
};
|
||
self.element_meta
|
||
.insert(new_id, ElementMeta { description: desc });
|
||
|
||
// Update element_id in line_select so subsequent ops find the right element.
|
||
if let Some(mode) = self.line_select.as_mut() {
|
||
mode.element_id = new_id;
|
||
}
|
||
}
|
||
|
||
fn update_line_select_status(&mut self) {
|
||
if let Some(mode) = &self.line_select {
|
||
let line_info = format!("L{}/{}", mode.cursor_line + 1, mode.total_lines());
|
||
let sel_info = match mode.selection {
|
||
SelectionState::None => String::new(),
|
||
SelectionState::Selecting(anchor) => {
|
||
let (s, e) = sorted(anchor, mode.cursor_line);
|
||
format!(" | selecting {}‑{}", s + 1, e + 1)
|
||
}
|
||
SelectionState::Locked(s, e) => format!(" | locked {}‑{}", s + 1, e + 1),
|
||
};
|
||
let goto_info = if mode.goto_buf.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(" | :{}", mode.goto_buf)
|
||
};
|
||
self.status = format!("{} {line_info}{sel_info}{goto_info}", mode.file_path);
|
||
}
|
||
}
|
||
|
||
// ── Rendering ──
|
||
|
||
fn render(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||
terminal.draw(|f| {
|
||
let area = f.area();
|
||
|
||
if self.line_select.is_some() {
|
||
// ── Line select layout: preview + hints + prompt + status ──
|
||
let [preview_area, hints_area, prompt_outer, status_area] = Layout::vertical([
|
||
Constraint::Min(5),
|
||
Constraint::Length(1),
|
||
Constraint::Length(5),
|
||
Constraint::Length(1),
|
||
])
|
||
.areas(area);
|
||
|
||
// Update viewport height in the mode so scrolling works correctly.
|
||
if let Some(mode) = self.line_select.as_mut() {
|
||
// Reserve 2 rows for border.
|
||
mode.viewport_height = preview_area.height.saturating_sub(2) as usize;
|
||
}
|
||
|
||
self.render_line_select(f.buffer_mut(), preview_area);
|
||
self.render_line_select_hints(f.buffer_mut(), hints_area);
|
||
self.render_prompt(f, prompt_outer);
|
||
|
||
let status = Paragraph::new(Line::from(vec![
|
||
Span::styled(" ", Style::default()),
|
||
Span::styled(&self.status, Style::default().fg(Color::DarkGray)),
|
||
]));
|
||
status.render(status_area, f.buffer_mut());
|
||
} else {
|
||
// ── Normal layout ──
|
||
let info_rows: u16 = 16;
|
||
let fs_rows = if self.fs_active {
|
||
self.file_search.dropdown_height()
|
||
} else {
|
||
0
|
||
};
|
||
// Ensure the prompt gets at least 5 rows (3 inner + border).
|
||
let min_prompt: u16 = 5;
|
||
let fixed = info_rows + 1 + fs_rows + 1;
|
||
let remaining = area.height.saturating_sub(fixed);
|
||
let half = (remaining / 2).max(min_prompt);
|
||
|
||
let [
|
||
info_area,
|
||
_gap,
|
||
raw_buf_area,
|
||
fs_area,
|
||
prompt_outer,
|
||
status_area,
|
||
] = Layout::vertical([
|
||
Constraint::Length(info_rows),
|
||
Constraint::Length(1),
|
||
Constraint::Length(half),
|
||
Constraint::Length(fs_rows),
|
||
Constraint::Length(half),
|
||
Constraint::Length(1),
|
||
])
|
||
.areas(area);
|
||
|
||
self.render_info(f.buffer_mut(), info_area);
|
||
self.render_raw_buffer(f.buffer_mut(), raw_buf_area);
|
||
|
||
if fs_rows > 0 {
|
||
self.render_file_search(f.buffer_mut(), fs_area);
|
||
}
|
||
|
||
self.render_prompt(f, prompt_outer);
|
||
|
||
let status = Paragraph::new(Line::from(vec![
|
||
Span::styled(" ", Style::default()),
|
||
Span::styled(&self.status, Style::default().fg(Color::DarkGray)),
|
||
]));
|
||
status.render(status_area, f.buffer_mut());
|
||
}
|
||
|
||
// ── Cursor management (inside draw) ──
|
||
//
|
||
// By calling set_cursor_position inside the draw closure,
|
||
// ratatui emits show_cursor + set_cursor_position WITHOUT
|
||
// the hide_cursor that happens when no cursor is set. This
|
||
// avoids the hide→show cycle that resets the terminal's
|
||
// blink timer every frame.
|
||
let want_cursor = if self.line_select.is_none() {
|
||
self.textarea
|
||
.cursor_pos_with_state(self.textarea_area, self.textarea_state)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if let Some((cx, cy)) = want_cursor {
|
||
f.set_cursor_position(ratatui::layout::Position { x: cx, y: cy });
|
||
}
|
||
})?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Render the prompt box (shared between normal and line-select layouts).
|
||
fn render_prompt(&mut self, f: &mut ratatui::Frame<'_>, prompt_outer: Rect) {
|
||
let prompt_block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(Style::default().fg(Color::DarkGray))
|
||
.title(Span::styled(
|
||
" Prompt ",
|
||
Style::default().fg(Color::White).bold(),
|
||
));
|
||
let prompt_inner = prompt_block.inner(prompt_outer);
|
||
prompt_block.render(prompt_outer, f.buffer_mut());
|
||
|
||
if prompt_inner.width > 2 {
|
||
let [char_area, textarea_area] =
|
||
Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).areas(prompt_inner);
|
||
|
||
let prompt_char = Span::styled("❯ ", Style::default().fg(Color::Magenta).bold());
|
||
f.buffer_mut().set_string(
|
||
char_area.x,
|
||
char_area.y,
|
||
&prompt_char.content,
|
||
prompt_char.style,
|
||
);
|
||
|
||
StatefulWidgetRef::render_ref(
|
||
&(&self.textarea),
|
||
textarea_area,
|
||
f.buffer_mut(),
|
||
&mut self.textarea_state,
|
||
);
|
||
|
||
// Store the textarea render area for mouse mapping.
|
||
self.textarea_area = textarea_area;
|
||
}
|
||
}
|
||
|
||
/// Render the file preview with line numbers and selection highlighting.
|
||
fn render_line_select(&self, buf: &mut ratatui::buffer::Buffer, area: Rect) {
|
||
let Some(mode) = &self.line_select else {
|
||
return;
|
||
};
|
||
|
||
// Title shows file path + range if any.
|
||
let title = match mode.effective_range() {
|
||
Some(r) if r.start() == r.end() => format!(" {} :{} ", mode.file_path, r.start()),
|
||
Some(r) => format!(" {} :{}-{} ", mode.file_path, r.start(), r.end()),
|
||
None => format!(" {} ", mode.file_path),
|
||
};
|
||
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(Style::default().fg(Color::Rgb(80, 80, 80)))
|
||
.title(Span::styled(
|
||
title,
|
||
Style::default().fg(Color::White).bold(),
|
||
));
|
||
let inner = block.inner(area);
|
||
block.render(area, buf);
|
||
|
||
if inner.width < 6 || inner.height == 0 {
|
||
return;
|
||
}
|
||
|
||
let total = mode.total_lines();
|
||
let gutter_width = total.to_string().len();
|
||
let code_start_col = inner.x + gutter_width as u16 + 1; // +1 for separator
|
||
let code_width = inner.width.saturating_sub(gutter_width as u16 + 1);
|
||
|
||
// Styles.
|
||
let gutter_style = Style::default().fg(Color::Rgb(80, 80, 80));
|
||
let gutter_cursor_style = Style::default().fg(Color::Yellow);
|
||
let code_style = Style::default().fg(Color::Rgb(200, 200, 200));
|
||
let cursor_bg = Style::default().fg(Color::White).bg(Color::Rgb(50, 50, 60));
|
||
let selecting_bg = Style::default().fg(Color::White).bg(Color::Rgb(30, 60, 30));
|
||
let locked_bg = Style::default().fg(Color::White).bg(Color::Rgb(70, 35, 35));
|
||
let sep = "│";
|
||
|
||
for row in 0..inner.height as usize {
|
||
let line_idx = mode.scroll_top + row;
|
||
if line_idx >= total {
|
||
break;
|
||
}
|
||
let y = inner.y + row as u16;
|
||
let line_num = line_idx + 1; // 1-indexed for display
|
||
|
||
// Determine line style.
|
||
let is_cursor = line_idx == mode.cursor_line;
|
||
let is_sel = mode.is_selected(line_idx);
|
||
|
||
let line_style = if is_cursor {
|
||
cursor_bg
|
||
} else if is_sel {
|
||
match mode.selection {
|
||
SelectionState::Selecting(_) => selecting_bg,
|
||
SelectionState::Locked(_, _) => locked_bg,
|
||
SelectionState::None => code_style,
|
||
}
|
||
} else {
|
||
code_style
|
||
};
|
||
|
||
// Line number gutter.
|
||
let num_str = format!("{:>width$}", line_num, width = gutter_width);
|
||
let g_style = if is_cursor {
|
||
gutter_cursor_style
|
||
} else {
|
||
gutter_style
|
||
};
|
||
buf.set_string(inner.x, y, &num_str, g_style);
|
||
buf.set_string(inner.x + gutter_width as u16, y, sep, gutter_style);
|
||
|
||
// Code content.
|
||
let content = &mode.lines[line_idx];
|
||
// Fill the entire code area with background first if highlighted.
|
||
if is_cursor || is_sel {
|
||
for col in 0..code_width {
|
||
buf.set_string(code_start_col + col, y, " ", line_style);
|
||
}
|
||
}
|
||
// Render the actual text (truncate to fit).
|
||
let display: String = content.chars().take(code_width as usize).collect();
|
||
buf.set_string(code_start_col, y, &display, line_style);
|
||
}
|
||
|
||
// Render the goto-line input at the bottom-right of the preview if active.
|
||
if !mode.goto_buf.is_empty() {
|
||
let goto_str = format!(":{}", mode.goto_buf);
|
||
let w = goto_str.len() as u16;
|
||
let x = area.x + area.width.saturating_sub(w + 2);
|
||
let y = area.y + area.height.saturating_sub(1);
|
||
buf.set_string(x, y, &goto_str, Style::default().fg(Color::Yellow).bold());
|
||
}
|
||
}
|
||
|
||
fn render_line_select_hints(&self, buf: &mut ratatui::buffer::Buffer, area: Rect) {
|
||
let k = Style::default().fg(Color::Yellow);
|
||
let d = Style::default().fg(Color::DarkGray);
|
||
let s = Style::default().fg(Color::Rgb(50, 50, 50));
|
||
|
||
let hints = Line::from(vec![
|
||
Span::styled(" j/k", k),
|
||
Span::styled(" ↕ ", d),
|
||
Span::styled("│", s),
|
||
Span::styled(" C-u/d", k),
|
||
Span::styled(" ½pg ", d),
|
||
Span::styled("│", s),
|
||
Span::styled(" f/b", k),
|
||
Span::styled(" pg ", d),
|
||
Span::styled("│", s),
|
||
Span::styled(" v/V", k),
|
||
Span::styled(" select ", d),
|
||
Span::styled("│", s),
|
||
Span::styled(" 0‑9", k),
|
||
Span::styled(" goto ", d),
|
||
Span::styled("│", s),
|
||
Span::styled(" g/G", k),
|
||
Span::styled(" top/bot ", d),
|
||
Span::styled("│", s),
|
||
Span::styled(" Enter", k),
|
||
Span::styled(" confirm ", d),
|
||
Span::styled("│", s),
|
||
Span::styled(" Esc/q", k),
|
||
Span::styled(" cancel", d),
|
||
]);
|
||
|
||
Paragraph::new(hints).render(area, buf);
|
||
}
|
||
|
||
fn render_file_search(&self, buf: &mut ratatui::buffer::Buffer, area: Rect) {
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(Style::default().fg(Color::Green))
|
||
.title(Span::styled(
|
||
" @ File Search ",
|
||
Style::default().fg(Color::Green).bold(),
|
||
));
|
||
let inner = block.inner(area);
|
||
block.render(area, buf);
|
||
|
||
if inner.width == 0 || inner.height == 0 {
|
||
return;
|
||
}
|
||
|
||
let match_style = Style::default().fg(Color::Yellow).bold();
|
||
let selected_style = Style::default().fg(Color::White);
|
||
let normal_style = Style::default().fg(Color::Gray);
|
||
let marker_style = Style::default().fg(Color::Green).bold();
|
||
let dim_style = Style::default().fg(Color::DarkGray);
|
||
|
||
for (i, result) in self
|
||
.file_search
|
||
.results
|
||
.iter()
|
||
.enumerate()
|
||
.take(inner.height as usize)
|
||
{
|
||
let is_selected = i == self.file_search.selected;
|
||
let y = inner.y + i as u16;
|
||
|
||
// Selection marker
|
||
let marker = if is_selected { "▸ " } else { " " };
|
||
buf.set_string(inner.x, y, marker, marker_style);
|
||
|
||
// Path with fuzzy-match highlighting
|
||
let base_style = if is_selected {
|
||
selected_style
|
||
} else {
|
||
normal_style
|
||
};
|
||
let mut col = inner.x + 2;
|
||
for (ci, ch) in result.path.chars().enumerate() {
|
||
if col >= inner.x + inner.width {
|
||
break;
|
||
}
|
||
let style = if result.indices.contains(&ci) {
|
||
match_style
|
||
} else {
|
||
base_style
|
||
};
|
||
let s = ch.to_string();
|
||
buf.set_string(col, y, &s, style);
|
||
col += unicode_width::UnicodeWidthStr::width(s.as_str()) as u16;
|
||
}
|
||
|
||
// Show score on the right for selected item
|
||
if is_selected && result.score > 0 {
|
||
let score_str = format!(" [{}]", result.score);
|
||
let score_w = score_str.len() as u16;
|
||
if inner.width > score_w + 4 {
|
||
let sx = inner.x + inner.width - score_w;
|
||
buf.set_string(sx, y, &score_str, dim_style);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_raw_buffer(&self, buf: &mut ratatui::buffer::Buffer, area: Rect) {
|
||
// Build the block title: "Raw Buffer" + clipboard summary.
|
||
let mut title_spans = vec![Span::styled(
|
||
" Raw Buffer ",
|
||
Style::default().fg(Color::Rgb(120, 120, 120)),
|
||
)];
|
||
if let Some(clip) = self.textarea.clipboard() {
|
||
let preview: String = clip.chars().take(40).collect();
|
||
let suffix = if clip.chars().count() > 40 { "…" } else { "" };
|
||
title_spans.push(Span::styled(
|
||
format!("│ clipboard: {preview}{suffix} "),
|
||
Style::default().fg(Color::Rgb(80, 140, 80)),
|
||
));
|
||
}
|
||
|
||
let raw_block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(Style::default().fg(Color::Rgb(60, 60, 60)))
|
||
.title(Line::from(title_spans));
|
||
let inner = raw_block.inner(area);
|
||
raw_block.render(area, buf);
|
||
|
||
if inner.width == 0 || inner.height == 0 {
|
||
return;
|
||
}
|
||
|
||
let text = self.textarea.text();
|
||
let elements = self.textarea.elements();
|
||
let plain_style = Style::default().fg(Color::Rgb(160, 160, 160));
|
||
let plain_nl = Style::default().fg(Color::Rgb(80, 80, 80));
|
||
let elem_style = Style::default().fg(Color::Rgb(200, 140, 60));
|
||
let elem_nl = Style::default().fg(Color::Rgb(120, 80, 30));
|
||
|
||
let mut display_spans: Vec<Span<'_>> = Vec::new();
|
||
let mut pos = 0;
|
||
|
||
for elem in elements {
|
||
if pos < elem.range.start {
|
||
push_with_visible_newlines(
|
||
&text[pos..elem.range.start],
|
||
plain_style,
|
||
plain_nl,
|
||
&mut display_spans,
|
||
);
|
||
}
|
||
push_with_visible_newlines(
|
||
&text[elem.range.clone()],
|
||
elem_style,
|
||
elem_nl,
|
||
&mut display_spans,
|
||
);
|
||
pos = elem.range.end;
|
||
}
|
||
|
||
if pos < text.len() {
|
||
push_with_visible_newlines(&text[pos..], plain_style, plain_nl, &mut display_spans);
|
||
}
|
||
|
||
let line = Line::from(display_spans);
|
||
let opts = RtOptions::new(inner.width as usize).break_words(true);
|
||
let wrapped = word_wrap_line(&line, opts);
|
||
|
||
let para = Paragraph::new(Text::from(wrapped));
|
||
para.render(inner, buf);
|
||
}
|
||
|
||
fn render_info(&self, buf: &mut ratatui::buffer::Buffer, area: Rect) {
|
||
let mut lines = vec![
|
||
Line::from(vec![
|
||
Span::styled("TextArea Demo", Style::default().fg(Color::White).bold()),
|
||
Span::styled(
|
||
" — @-File-Search + Atomic Elements",
|
||
Style::default().fg(Color::DarkGray),
|
||
),
|
||
]),
|
||
Line::from(""),
|
||
];
|
||
|
||
let bindings = vec![
|
||
("@query", "trigger file search"),
|
||
("Tab / Enter", "confirm file selection"),
|
||
("↑/↓ / C-p/C-n", "navigate results"),
|
||
("Esc", "dismiss search / quit"),
|
||
("Paste (Cmd+V)", "create paste element"),
|
||
("i", "inline element at cursor"),
|
||
("←/→", "navigate (jumps over elements)"),
|
||
("Backspace/Del", "delete (atomic for elements)"),
|
||
("Alt+←/→", "word navigation"),
|
||
("Ctrl+A/E", "beginning/end of line"),
|
||
("Ctrl+K/U", "kill to end/beginning of line"),
|
||
("Ctrl+Z", "undo"),
|
||
("Ctrl+Shift+Z/Y", "redo"),
|
||
("Ctrl+C", "clear (quit if empty)"),
|
||
];
|
||
|
||
for (key, desc) in bindings {
|
||
lines.push(Line::from(vec![
|
||
Span::styled(format!("{:>16}", key), Style::default().fg(Color::Yellow)),
|
||
Span::styled(" ", Style::default()),
|
||
Span::styled(desc, Style::default().fg(Color::Gray)),
|
||
]));
|
||
}
|
||
|
||
let text = Text::from(lines);
|
||
let para = Paragraph::new(text);
|
||
para.render(area, buf);
|
||
}
|
||
}
|
||
|
||
/// Split `s` at newlines, push text segments with `text_style` and
|
||
/// literal `\n` markers with `nl_style` (dim).
|
||
fn push_with_visible_newlines<'a>(
|
||
s: &'a str,
|
||
text_style: Style,
|
||
nl_style: Style,
|
||
out: &mut Vec<Span<'a>>,
|
||
) {
|
||
let mut first = true;
|
||
for part in s.split('\n') {
|
||
if !first {
|
||
out.push(Span::styled("\\n", nl_style));
|
||
}
|
||
if !part.is_empty() {
|
||
out.push(Span::styled(part, text_style));
|
||
}
|
||
first = false;
|
||
}
|
||
}
|
||
|
||
fn main() -> io::Result<()> {
|
||
terminal::enable_raw_mode()?;
|
||
stdout().execute(EnterAlternateScreen)?;
|
||
stdout().execute(EnableBracketedPaste)?;
|
||
stdout().execute(EnableMouseCapture)?;
|
||
stdout().execute(EnableBlinking)?;
|
||
stdout().execute(SetCursorStyle::BlinkingBlock)?;
|
||
|
||
let backend = CrosstermBackend::new(stdout());
|
||
let mut terminal = Terminal::new(backend)?;
|
||
|
||
let result = run(&mut terminal);
|
||
|
||
let _ = stdout().execute(DisableMouseCapture);
|
||
let _ = stdout().execute(DisableBracketedPaste);
|
||
let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
|
||
let _ = terminal::disable_raw_mode();
|
||
let _ = stdout().execute(LeaveAlternateScreen);
|
||
|
||
if let Err(ref e) = result {
|
||
eprintln!("textarea_demo exited with error: {e}");
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||
let mut app = DemoApp::new();
|
||
app.render(terminal)?;
|
||
|
||
loop {
|
||
// The textarea tells us when it needs a timer tick (e.g. for
|
||
// continuous drag-scrolling). Use its timeout for poll, falling
|
||
// back to a generous default that lets the cursor blink.
|
||
let timeout = app
|
||
.textarea
|
||
.poll_timeout_ms()
|
||
.map(Duration::from_millis)
|
||
.unwrap_or(Duration::from_millis(100));
|
||
|
||
if crossterm::event::poll(timeout)? {
|
||
let event = crossterm::event::read()?;
|
||
match app.handle_event(event) {
|
||
EventResult::Quit => break,
|
||
EventResult::Redraw => app.render(terminal)?,
|
||
EventResult::Unchanged => {}
|
||
}
|
||
}
|
||
|
||
// Whether we processed an event or timed out, check if the
|
||
// textarea has pending timer work (e.g. continuous drag-scroll).
|
||
// The textarea's internal throttle prevents this from firing
|
||
// too fast — it'll return Nothing if not enough time has passed.
|
||
if app.textarea.poll_timeout_ms().is_some() {
|
||
let action = app.textarea.tick(app.textarea_area, app.textarea_state);
|
||
if matches!(action, MouseAction::SelectionUpdated) {
|
||
if let Some(text) = app.textarea.selected_text() {
|
||
let chars = text.chars().count();
|
||
app.status = format!("Selecting… ({chars} chars)");
|
||
}
|
||
app.render(terminal)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|