feat(tui): add mouse input handling and layout snapshot for region detection

- Extend event handling to include `MouseEvent` and expose it via a new `Event::Mouse` variant.
- Introduce `LayoutSnapshot` to capture the geometry of UI panels each render cycle.
- Store the latest layout snapshot in `ChatApp` for mouse region lookup.
- Implement mouse click and scroll handling across panels (file tree, thinking, actions, code, model info, chat, input, etc.).
- Add utility functions for region detection, cursor placement from mouse position, and scrolling logic.
- Update UI rendering to populate the layout snapshot with panel rectangles.
This commit is contained in:
2025-10-18 04:11:29 +02:00
parent d86888704f
commit 02f25b7bec
3 changed files with 327 additions and 5 deletions

View File

@@ -2,7 +2,7 @@ use anyhow::{Context, Result, anyhow};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Local, Utc}; use chrono::{DateTime, Local, Utc};
use crossterm::{ use crossterm::{
event::KeyEvent, event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
terminal::{disable_raw_mode, enable_raw_mode}, terminal::{disable_raw_mode, enable_raw_mode},
}; };
use owlen_core::consent::ConsentScope; use owlen_core::consent::ConsentScope;
@@ -26,8 +26,11 @@ use owlen_core::{
}; };
use owlen_markdown::from_str; use owlen_markdown::from_str;
use pathdiff::diff_paths; use pathdiff::diff_paths;
use ratatui::style::{Color, Modifier, Style}; use ratatui::{
use ratatui::text::{Line, Span}; layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
};
use textwrap::{Options, WordSeparator, wrap}; use textwrap::{Options, WordSeparator, wrap};
use tokio::{ use tokio::{
sync::mpsc, sync::mpsc,
@@ -95,6 +98,122 @@ const RESIZE_STEP: f32 = 0.05;
const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25];
const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500);
pub(crate) const MIN_MESSAGE_CARD_WIDTH: usize = 14; pub(crate) const MIN_MESSAGE_CARD_WIDTH: usize = 14;
const MOUSE_SCROLL_STEP: isize = 3;
#[derive(Clone, Copy, Debug)]
pub(crate) struct LayoutSnapshot {
pub(crate) frame: Rect,
pub(crate) content: Rect,
pub(crate) file_panel: Option<Rect>,
pub(crate) chat_panel: Option<Rect>,
pub(crate) thinking_panel: Option<Rect>,
pub(crate) actions_panel: Option<Rect>,
pub(crate) input_panel: Option<Rect>,
pub(crate) system_panel: Option<Rect>,
pub(crate) status_panel: Option<Rect>,
pub(crate) code_panel: Option<Rect>,
pub(crate) model_info_panel: Option<Rect>,
}
impl LayoutSnapshot {
pub(crate) fn new(frame: Rect, content: Rect) -> Self {
Self {
frame,
content,
file_panel: None,
chat_panel: None,
thinking_panel: None,
actions_panel: None,
input_panel: None,
system_panel: None,
status_panel: None,
code_panel: None,
model_info_panel: None,
}
}
fn contains(rect: Rect, column: u16, row: u16) -> bool {
let x_end = rect.x.saturating_add(rect.width);
let y_end = rect.y.saturating_add(rect.height);
column >= rect.x && column < x_end && row >= rect.y && row < y_end
}
fn region_at(&self, column: u16, row: u16) -> Option<UiRegion> {
if let Some(rect) = self.model_info_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::ModelInfo);
}
}
if let Some(rect) = self.code_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Code);
}
}
if let Some(rect) = self.file_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::FileTree);
}
}
if let Some(rect) = self.input_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Input);
}
}
if let Some(rect) = self.system_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::System);
}
}
if let Some(rect) = self.status_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Status);
}
}
if let Some(rect) = self.actions_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Actions);
}
}
if let Some(rect) = self.thinking_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Thinking);
}
}
if let Some(rect) = self.chat_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Chat);
}
}
if Self::contains(self.content, column, row) {
Some(UiRegion::Content)
} else if Self::contains(self.frame, column, row) {
Some(UiRegion::Frame)
} else {
None
}
}
}
impl Default for LayoutSnapshot {
fn default() -> Self {
Self::new(Rect::new(0, 0, 0, 0), Rect::new(0, 0, 0, 0))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UiRegion {
Frame,
Content,
FileTree,
Chat,
Thinking,
Actions,
Input,
System,
Status,
Code,
ModelInfo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SlashOutcome { enum SlashOutcome {
@@ -484,6 +603,7 @@ pub struct ChatApp {
system_status: String, // System/status messages (tool execution, status, etc) system_status: String, // System/status messages (tool execution, status, etc)
toasts: ToastManager, toasts: ToastManager,
debug_log: DebugLogState, debug_log: DebugLogState,
last_layout: LayoutSnapshot,
/// Simple execution budget: maximum number of tool calls allowed per session. /// Simple execution budget: maximum number of tool calls allowed per session.
_execution_budget: usize, _execution_budget: usize,
/// Agent mode enabled /// Agent mode enabled
@@ -754,6 +874,7 @@ impl ChatApp {
}, },
toasts: ToastManager::new(), toasts: ToastManager::new(),
debug_log: DebugLogState::new(), debug_log: DebugLogState::new(),
last_layout: LayoutSnapshot::default(),
_execution_budget: 50, _execution_budget: 50,
agent_mode: false, agent_mode: false,
agent_running: false, agent_running: false,
@@ -1872,6 +1993,14 @@ impl ChatApp {
&self.theme &self.theme
} }
pub(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) {
self.last_layout = snapshot;
}
fn region_for_position(&self, column: u16, row: u16) -> Option<UiRegion> {
self.last_layout.region_at(column, row)
}
pub fn is_debug_log_visible(&self) -> bool { pub fn is_debug_log_visible(&self) -> bool {
self.debug_log.is_visible() self.debug_log.is_visible()
} }
@@ -4973,6 +5102,9 @@ impl ChatApp {
} }
// Ignore paste events in other modes // Ignore paste events in other modes
} }
Event::Mouse(mouse) => {
return self.handle_mouse_event(mouse);
}
Event::Key(key) => { Event::Key(key) => {
let is_ctrl_c = matches!( let is_ctrl_c = matches!(
(key.code, key.modifiers), (key.code, key.modifiers),
@@ -7631,6 +7763,171 @@ impl ChatApp {
Ok(AppState::Running) Ok(AppState::Running)
} }
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<AppState> {
if self.has_pending_consent() {
return Ok(AppState::Running);
}
let region = self.region_for_position(mouse.column, mouse.row);
match mouse.kind {
MouseEventKind::ScrollUp => {
if let Some(region) = region {
self.handle_mouse_scroll(region, -MOUSE_SCROLL_STEP);
}
}
MouseEventKind::ScrollDown => {
if let Some(region) = region {
self.handle_mouse_scroll(region, MOUSE_SCROLL_STEP);
}
}
MouseEventKind::Down(MouseButton::Left) => {
self.pending_key = None;
if let Some(region) = region {
self.handle_mouse_click(region, mouse.column, mouse.row);
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if matches!(region, Some(UiRegion::Input)) {
self.pending_key = None;
self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row);
}
}
_ => {}
}
Ok(AppState::Running)
}
fn handle_mouse_scroll(&mut self, region: UiRegion, amount: isize) {
if amount == 0 {
return;
}
match region {
UiRegion::FileTree => {
if self.focus_panel(FocusedPanel::Files) {
self.file_tree_mut().move_cursor(amount);
}
}
UiRegion::Thinking | UiRegion::Actions => {
if self.focus_panel(FocusedPanel::Thinking) {
let viewport = self.thinking_viewport_height.max(1);
self.thinking_scroll.on_user_scroll(amount, viewport);
}
}
UiRegion::Code => {
if self.focus_panel(FocusedPanel::Code) {
let viewport = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.on_user_scroll(amount, viewport);
}
}
}
UiRegion::ModelInfo => {
self.scroll_model_info(amount);
}
UiRegion::Input => {}
UiRegion::System
| UiRegion::Status
| UiRegion::Chat
| UiRegion::Content
| UiRegion::Frame => {
if self.focus_panel(FocusedPanel::Chat) {
self.auto_scroll
.on_user_scroll(amount, self.viewport_height);
self.update_new_message_alert_after_scroll();
}
}
}
}
fn handle_mouse_click(&mut self, region: UiRegion, column: u16, row: u16) {
match region {
UiRegion::FileTree => {
self.focus_panel(FocusedPanel::Files);
self.set_input_mode(InputMode::Normal);
}
UiRegion::Thinking | UiRegion::Actions => {
if self.focus_panel(FocusedPanel::Thinking) {
self.set_input_mode(InputMode::Normal);
}
}
UiRegion::Code => {
if self.focus_panel(FocusedPanel::Code) {
self.set_input_mode(InputMode::Normal);
}
}
UiRegion::Input => {
self.focus_panel(FocusedPanel::Input);
self.set_input_mode(InputMode::Editing);
if let Some(rect) = self.last_layout.input_panel {
if let Some((line, column)) = self.input_cursor_from_point(rect, column, row) {
let line = line.min(u16::MAX as usize) as u16;
let column = column.min(u16::MAX as usize) as u16;
self.textarea.move_cursor(CursorMove::Jump(line, column));
}
}
}
UiRegion::ModelInfo => {
self.set_input_mode(InputMode::Normal);
}
UiRegion::System
| UiRegion::Status
| UiRegion::Chat
| UiRegion::Content
| UiRegion::Frame => {
self.focus_panel(FocusedPanel::Chat);
self.set_input_mode(InputMode::Normal);
}
}
}
fn input_cursor_from_point(&self, rect: Rect, column: u16, row: u16) -> Option<(usize, usize)> {
let lines = self.textarea.lines();
if lines.is_empty() {
return Some((0, 0));
}
let inner_x = usize::from(column.saturating_sub(rect.x.saturating_add(1)));
let inner_y = usize::from(row.saturating_sub(rect.y.saturating_add(1)));
let max_line = lines.len().saturating_sub(1);
let line_index = inner_y.min(max_line);
let column_index = Self::grapheme_index_for_visual_offset(&lines[line_index], inner_x);
Some((line_index, column_index))
}
fn scroll_model_info(&mut self, amount: isize) {
if amount == 0 {
return;
}
let steps = amount.unsigned_abs();
let viewport = self.model_info_viewport_height.max(1);
if amount.is_positive() {
for _ in 0..steps {
self.model_info_panel.scroll_down(viewport);
}
} else {
for _ in 0..steps {
self.model_info_panel.scroll_up();
}
}
}
fn grapheme_index_for_visual_offset(line: &str, offset: usize) -> usize {
let mut width = 0usize;
for (idx, grapheme) in line.graphemes(true).enumerate() {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if width + grapheme_width > offset {
return idx;
}
width += grapheme_width;
}
line.graphemes(true).count()
}
/// Call this when processing scroll up/down keys /// Call this when processing scroll up/down keys
pub fn on_scroll(&mut self, delta: isize) { pub fn on_scroll(&mut self, delta: isize) {
match self.focused_panel { match self.focused_panel {

View File

@@ -1,4 +1,4 @@
use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent};
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -8,6 +8,8 @@ use tokio_util::sync::CancellationToken;
pub enum Event { pub enum Event {
/// Terminal key press event /// Terminal key press event
Key(KeyEvent), Key(KeyEvent),
/// Mouse input event
Mouse(MouseEvent),
/// Terminal resize event /// Terminal resize event
#[allow(dead_code)] #[allow(dead_code)]
Resize(u16, u16), Resize(u16, u16),
@@ -27,6 +29,7 @@ pub fn from_crossterm_event(raw: crossterm::event::Event) -> Option<Event> {
None None
} }
} }
crossterm::event::Event::Mouse(mouse) => Some(Event::Mouse(mouse)),
crossterm::event::Event::Resize(width, height) => Some(Event::Resize(width, height)), crossterm::event::Event::Resize(width, height) => Some(Event::Resize(width, height)),
crossterm::event::Event::Paste(text) => Some(Event::Paste(text)), crossterm::event::Event::Paste(text) => Some(Event::Paste(text)),
_ => None, _ => None,

View File

@@ -12,7 +12,9 @@ use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext}; use crate::chat_app::{
ChatApp, HELP_TAB_COUNT, LayoutSnapshot, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext,
};
use crate::highlight; use crate::highlight;
use crate::state::{ use crate::state::{
CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId,
@@ -204,9 +206,11 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
.title(title_line); .title(title_line);
let content_area = main_block.inner(frame_area); let content_area = main_block.inner(frame_area);
let mut snapshot = LayoutSnapshot::new(frame_area, content_area);
frame.render_widget(main_block, frame_area); frame.render_widget(main_block, frame_area);
if content_area.width == 0 || content_area.height == 0 { if content_area.width == 0 || content_area.height == 0 {
app.set_layout_snapshot(snapshot);
return; return;
} }
@@ -240,6 +244,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
}; };
if let Some(file_area) = file_area { if let Some(file_area) = file_area {
snapshot.file_panel = Some(file_area);
render_file_tree(frame, file_area, app); render_file_tree(frame, file_area, app);
} }
@@ -308,25 +313,35 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
.split(chat_area); .split(chat_area);
let mut idx = 0; let mut idx = 0;
snapshot.chat_panel = Some(layout[idx]);
render_messages(frame, layout[idx], app); render_messages(frame, layout[idx], app);
idx += 1; idx += 1;
if thinking_height > 0 { if thinking_height > 0 {
snapshot.thinking_panel = Some(layout[idx]);
render_thinking(frame, layout[idx], app); render_thinking(frame, layout[idx], app);
idx += 1; idx += 1;
} else {
snapshot.thinking_panel = None;
} }
// Render agent actions panel if present // Render agent actions panel if present
if actions_height > 0 { if actions_height > 0 {
snapshot.actions_panel = Some(layout[idx]);
render_agent_actions(frame, layout[idx], app); render_agent_actions(frame, layout[idx], app);
idx += 1; idx += 1;
} else {
snapshot.actions_panel = None;
} }
snapshot.input_panel = Some(layout[idx]);
render_input(frame, layout[idx], app); render_input(frame, layout[idx], app);
idx += 1; idx += 1;
snapshot.system_panel = Some(layout[idx]);
render_system_output(frame, layout[idx], app, &status_message); render_system_output(frame, layout[idx], app, &status_message);
idx += 1; idx += 1;
snapshot.status_panel = Some(layout[idx]);
render_status(frame, layout[idx], app); render_status(frame, layout[idx], app);
// Render consent dialog with highest priority (always on top) // Render consent dialog with highest priority (always on top)
@@ -357,14 +372,20 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
.x .x
.saturating_add(content_area.width.saturating_sub(panel_width)); .saturating_add(content_area.width.saturating_sub(panel_width));
let area = Rect::new(x, content_area.y, panel_width, content_area.height); let area = Rect::new(x, content_area.y, panel_width, content_area.height);
snapshot.model_info_panel = Some(area);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
let viewport_height = area.height.saturating_sub(2) as usize; let viewport_height = area.height.saturating_sub(2) as usize;
app.set_model_info_viewport_height(viewport_height); app.set_model_info_viewport_height(viewport_height);
app.model_info_panel_mut().render(frame, area, &theme); app.model_info_panel_mut().render(frame, area, &theme);
} else {
snapshot.model_info_panel = None;
} }
if let Some(area) = code_area { if let Some(area) = code_area {
snapshot.code_panel = Some(area);
render_code_workspace(frame, area, app); render_code_workspace(frame, area, app);
} else {
snapshot.code_panel = None;
} }
if app.is_debug_log_visible() { if app.is_debug_log_visible() {
@@ -381,6 +402,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
} }
} }
app.set_layout_snapshot(snapshot);
render_toasts(frame, app, content_area); render_toasts(frame, app, content_area);
} }