Feat: text selection using mouse

This commit is contained in:
2026-02-25 23:20:42 +01:00
parent 03c8187359
commit aa607a78d8
5 changed files with 125 additions and 6 deletions

View File

@@ -99,6 +99,14 @@ impl Editor {
self.text.is_selecting()
}
pub fn move_cursor_to(&mut self, row: u16, col: u16) {
self.text.move_cursor(tui_textarea::CursorMove::Jump(row, col));
}
pub fn scroll_offset(&self) -> u16 {
self.scroll_offset.get()
}
pub fn copy(&mut self) {
self.text.copy();
}

View File

@@ -22,7 +22,7 @@ use cagire::engine::{
};
use cagire::init::{init, InitArgs};
use cagire::input::{handle_key, handle_mouse, InputContext, InputResult};
use cagire::input_egui::{convert_egui_events, convert_egui_mouse};
use cagire::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState};
use cagire::settings::Settings;
use cagire::views;
use crossbeam_channel::Receiver;
@@ -168,6 +168,7 @@ struct CagireDesktop {
mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>,
last_frame: std::time::Instant,
egui_mouse: EguiMouseState,
}
impl CagireDesktop {
@@ -212,6 +213,7 @@ impl CagireDesktop {
mouse_y: b.mouse_y,
mouse_down: b.mouse_down,
last_frame: std::time::Instant::now(),
egui_mouse: EguiMouseState::default(),
}
}
@@ -322,7 +324,7 @@ impl CagireDesktop {
let term = self.terminal.get_frame().area();
let widget_rect = ctx.content_rect();
for mouse in convert_egui_mouse(ctx, widget_rect, term) {
for mouse in convert_egui_mouse(ctx, widget_rect, term, &mut self.egui_mouse) {
let mut input_ctx = InputContext {
app: &mut self.app,
link: &self.link,

View File

@@ -26,6 +26,12 @@ pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
match kind {
MouseEventKind::Down(MouseButton::Left) => handle_click(ctx, col, row, term),
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => {
handle_editor_drag(ctx, col, row, term);
}
MouseEventKind::Up(MouseButton::Left) => {
ctx.app.editor_ctx.mouse_selecting = false;
}
MouseEventKind::ScrollUp => handle_scroll(ctx, col, row, term, true),
MouseEventKind::ScrollDown => handle_scroll(ctx, col, row, term, false),
_ => {}
@@ -59,6 +65,55 @@ fn contains(area: Rect, col: u16, row: u16) -> bool {
col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height
}
fn handle_editor_drag(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
if ctx.app.editor_ctx.mouse_selecting {
handle_editor_mouse(ctx, col, row, term, true);
}
}
fn handle_editor_mouse(ctx: &mut InputContext, col: u16, row: u16, term: Rect, dragging: bool) {
// Reconstruct editor area (mirrors render_modal_editor / ModalFrame::render_centered)
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let modal_w = width.min(term.width.saturating_sub(4));
let modal_h = height.min(term.height.saturating_sub(4));
let mx = term.x + (term.width.saturating_sub(modal_w)) / 2;
let my = term.y + (term.height.saturating_sub(modal_h)) / 2;
// inner = area inside 1-cell border
let inner_x = mx + 1;
let inner_y = my + 1;
let inner_w = modal_w.saturating_sub(2);
let inner_h = modal_h.saturating_sub(2);
let show_search = ctx.app.editor_ctx.editor.search_active()
|| !ctx.app.editor_ctx.editor.search_query().is_empty();
let reserved = 1 + if show_search { 1 } else { 0 };
let editor_y = inner_y + if show_search { 1 } else { 0 };
let editor_h = inner_h.saturating_sub(reserved);
if col < inner_x || col >= inner_x + inner_w || row < editor_y || row >= editor_y + editor_h {
return;
}
let scroll = ctx.app.editor_ctx.editor.scroll_offset();
let text_row = (row - editor_y) + scroll;
let text_col = col - inner_x;
if dragging {
if !ctx.app.editor_ctx.editor.is_selecting() {
ctx.app.editor_ctx.editor.start_selection();
}
} else {
ctx.app.editor_ctx.mouse_selecting = true;
ctx.app.editor_ctx.editor.cancel_selection();
}
ctx.app
.editor_ctx
.editor
.move_cursor_to(text_row, text_col);
}
fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
// Sticky minimap intercepts all clicks
if matches!(ctx.app.ui.minimap, MinimapMode::Sticky) {
@@ -745,8 +800,11 @@ fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
match &ctx.app.ui.modal {
Modal::Editor | Modal::Preview => {
// Don't dismiss editor/preview on click
Modal::Editor => {
handle_editor_mouse(ctx, col, row, term, false);
}
Modal::Preview => {
// Don't dismiss preview on click
}
Modal::Confirm { .. } => {
handle_confirm_click(ctx, col, row, term);

View File

@@ -1,13 +1,25 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
#[derive(Default)]
pub struct EguiMouseState {
prev_down: bool,
prev_col: u16,
prev_row: u16,
}
pub fn convert_egui_mouse(
ctx: &egui::Context,
widget_rect: egui::Rect,
term: Rect,
state: &mut EguiMouseState,
) -> Vec<MouseEvent> {
let mut events = Vec::new();
if widget_rect.width() < 1.0 || widget_rect.height() < 1.0 || term.width == 0 || term.height == 0 {
if widget_rect.width() < 1.0
|| widget_rect.height() < 1.0
|| term.width == 0
|| term.height == 0
{
return events;
}
@@ -16,6 +28,15 @@ pub fn convert_egui_mouse(
return;
};
if !widget_rect.contains(pos) {
if state.prev_down && !i.pointer.primary_down() {
events.push(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: state.prev_col,
row: state.prev_row,
modifiers: KeyModifiers::empty(),
});
state.prev_down = false;
}
return;
}
@@ -26,15 +47,43 @@ pub fn convert_egui_mouse(
let col = col.min(term.width.saturating_sub(1));
let row = row.min(term.height.saturating_sub(1));
if i.pointer.button_clicked(egui::PointerButton::Primary) {
let is_down = i.pointer.primary_down();
let moved = col != state.prev_col || row != state.prev_row;
if !state.prev_down && is_down {
events.push(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
});
} else if state.prev_down && is_down && moved {
events.push(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
});
} else if state.prev_down && !is_down {
events.push(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
});
} else if !is_down && moved {
events.push(MouseEvent {
kind: MouseEventKind::Moved,
column: col,
row,
modifiers: KeyModifiers::empty(),
});
}
state.prev_down = is_down;
state.prev_col = col;
state.prev_row = row;
let scroll = i.raw_scroll_delta.y;
if scroll > 1.0 {
events.push(MouseEvent {

View File

@@ -99,6 +99,7 @@ pub struct EditorContext {
pub stack_cache: RefCell<Option<StackCache>>,
pub target: EditorTarget,
pub steps_per_page: Cell<usize>,
pub mouse_selecting: bool,
}
#[derive(Clone)]
@@ -150,6 +151,7 @@ impl Default for EditorContext {
stack_cache: RefCell::new(None),
target: EditorTarget::default(),
steps_per_page: Cell::new(32),
mouse_selecting: false,
}
}
}