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:
@@ -2,7 +2,7 @@ use anyhow::{Context, Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use crossterm::{
|
||||
event::KeyEvent,
|
||||
event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
|
||||
terminal::{disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use owlen_core::consent::ConsentScope;
|
||||
@@ -26,8 +26,11 @@ use owlen_core::{
|
||||
};
|
||||
use owlen_markdown::from_str;
|
||||
use pathdiff::diff_paths;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
};
|
||||
use textwrap::{Options, WordSeparator, wrap};
|
||||
use tokio::{
|
||||
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 DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500);
|
||||
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)]
|
||||
enum SlashOutcome {
|
||||
@@ -484,6 +603,7 @@ pub struct ChatApp {
|
||||
system_status: String, // System/status messages (tool execution, status, etc)
|
||||
toasts: ToastManager,
|
||||
debug_log: DebugLogState,
|
||||
last_layout: LayoutSnapshot,
|
||||
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||
_execution_budget: usize,
|
||||
/// Agent mode enabled
|
||||
@@ -754,6 +874,7 @@ impl ChatApp {
|
||||
},
|
||||
toasts: ToastManager::new(),
|
||||
debug_log: DebugLogState::new(),
|
||||
last_layout: LayoutSnapshot::default(),
|
||||
_execution_budget: 50,
|
||||
agent_mode: false,
|
||||
agent_running: false,
|
||||
@@ -1872,6 +1993,14 @@ impl ChatApp {
|
||||
&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 {
|
||||
self.debug_log.is_visible()
|
||||
}
|
||||
@@ -4973,6 +5102,9 @@ impl ChatApp {
|
||||
}
|
||||
// Ignore paste events in other modes
|
||||
}
|
||||
Event::Mouse(mouse) => {
|
||||
return self.handle_mouse_event(mouse);
|
||||
}
|
||||
Event::Key(key) => {
|
||||
let is_ctrl_c = matches!(
|
||||
(key.code, key.modifiers),
|
||||
@@ -7631,6 +7763,171 @@ impl ChatApp {
|
||||
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
|
||||
pub fn on_scroll(&mut self, delta: isize) {
|
||||
match self.focused_panel {
|
||||
|
||||
@@ -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 tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -8,6 +8,8 @@ use tokio_util::sync::CancellationToken;
|
||||
pub enum Event {
|
||||
/// Terminal key press event
|
||||
Key(KeyEvent),
|
||||
/// Mouse input event
|
||||
Mouse(MouseEvent),
|
||||
/// Terminal resize event
|
||||
#[allow(dead_code)]
|
||||
Resize(u16, u16),
|
||||
@@ -27,6 +29,7 @@ pub fn from_crossterm_event(raw: crossterm::event::Event) -> Option<Event> {
|
||||
None
|
||||
}
|
||||
}
|
||||
crossterm::event::Event::Mouse(mouse) => Some(Event::Mouse(mouse)),
|
||||
crossterm::event::Event::Resize(width, height) => Some(Event::Resize(width, height)),
|
||||
crossterm::event::Event::Paste(text) => Some(Event::Paste(text)),
|
||||
_ => None,
|
||||
|
||||
@@ -12,7 +12,9 @@ use tui_textarea::TextArea;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
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::state::{
|
||||
CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId,
|
||||
@@ -204,9 +206,11 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
||||
.title(title_line);
|
||||
|
||||
let content_area = main_block.inner(frame_area);
|
||||
let mut snapshot = LayoutSnapshot::new(frame_area, content_area);
|
||||
frame.render_widget(main_block, frame_area);
|
||||
|
||||
if content_area.width == 0 || content_area.height == 0 {
|
||||
app.set_layout_snapshot(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,6 +244,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
||||
};
|
||||
|
||||
if let Some(file_area) = file_area {
|
||||
snapshot.file_panel = Some(file_area);
|
||||
render_file_tree(frame, file_area, app);
|
||||
}
|
||||
|
||||
@@ -308,25 +313,35 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
||||
.split(chat_area);
|
||||
|
||||
let mut idx = 0;
|
||||
snapshot.chat_panel = Some(layout[idx]);
|
||||
render_messages(frame, layout[idx], app);
|
||||
idx += 1;
|
||||
|
||||
if thinking_height > 0 {
|
||||
snapshot.thinking_panel = Some(layout[idx]);
|
||||
render_thinking(frame, layout[idx], app);
|
||||
idx += 1;
|
||||
} else {
|
||||
snapshot.thinking_panel = None;
|
||||
}
|
||||
// Render agent actions panel if present
|
||||
if actions_height > 0 {
|
||||
snapshot.actions_panel = Some(layout[idx]);
|
||||
render_agent_actions(frame, layout[idx], app);
|
||||
idx += 1;
|
||||
} else {
|
||||
snapshot.actions_panel = None;
|
||||
}
|
||||
|
||||
snapshot.input_panel = Some(layout[idx]);
|
||||
render_input(frame, layout[idx], app);
|
||||
idx += 1;
|
||||
|
||||
snapshot.system_panel = Some(layout[idx]);
|
||||
render_system_output(frame, layout[idx], app, &status_message);
|
||||
idx += 1;
|
||||
|
||||
snapshot.status_panel = Some(layout[idx]);
|
||||
render_status(frame, layout[idx], app);
|
||||
|
||||
// Render consent dialog with highest priority (always on top)
|
||||
@@ -357,14 +372,20 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
||||
.x
|
||||
.saturating_add(content_area.width.saturating_sub(panel_width));
|
||||
let area = Rect::new(x, content_area.y, panel_width, content_area.height);
|
||||
snapshot.model_info_panel = Some(area);
|
||||
frame.render_widget(Clear, area);
|
||||
let viewport_height = area.height.saturating_sub(2) as usize;
|
||||
app.set_model_info_viewport_height(viewport_height);
|
||||
app.model_info_panel_mut().render(frame, area, &theme);
|
||||
} else {
|
||||
snapshot.model_info_panel = None;
|
||||
}
|
||||
|
||||
if let Some(area) = code_area {
|
||||
snapshot.code_panel = Some(area);
|
||||
render_code_workspace(frame, area, app);
|
||||
} else {
|
||||
snapshot.code_panel = None;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user