use std::time::{Duration, Instant};
use bmux_keyboard::{KeyCode, KeyStroke, Modifiers};
use bmux_text_edit::keyboard::TextKeymap;
use bmux_text_edit::{SelectionMode, TextEditBuffer, TextMotion};
use bmux_tui::event::{MouseButton, MouseEvent, MouseEventKind};
use bmux_tui::geometry::Rect;
use unicode_segmentation::UnicodeSegmentation;
const DEFAULT_MULTI_CLICK_WINDOW: Duration = Duration::from_millis(500);
const DEFAULT_MULTI_CLICK_DISTANCE: u16 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextInputState {
buffer: TextEditBuffer,
content_area: Rect,
vertical_scroll: usize,
mouse_selection: MouseSelectionState,
}
impl Default for TextInputState {
fn default() -> Self {
Self::new(TextEditBuffer::new())
}
}
impl TextInputState {
#[must_use]
pub fn new(buffer: TextEditBuffer) -> Self {
Self {
buffer,
content_area: Rect::new(0, 0, 1, 1),
vertical_scroll: 0,
mouse_selection: MouseSelectionState::default(),
}
}
#[must_use]
pub const fn buffer(&self) -> &TextEditBuffer {
&self.buffer
}
pub const fn buffer_mut(&mut self) -> &mut TextEditBuffer {
&mut self.buffer
}
#[must_use]
pub const fn content_area(&self) -> Rect {
self.content_area
}
#[must_use]
pub const fn vertical_scroll(&self) -> usize {
self.vertical_scroll
}
pub fn set_content_area(&mut self, area: Rect, policy: &TextInputPolicy) {
self.content_area = area;
self.sync_scroll_to_cursor(policy);
}
pub fn sync_scroll_to_cursor(&mut self, policy: &TextInputPolicy) {
if !policy.viewport.auto_scroll_to_cursor || self.content_area.height == 0 {
return;
}
self.vertical_scroll = cursor_scroll_offset(&self.buffer, self.content_area);
}
#[must_use]
pub const fn mouse_selection_active(&self) -> bool {
!matches!(self.mouse_selection.active, SelectionGranularity::Disabled)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextInputControl<'policy> {
policy: &'policy TextInputPolicy,
}
impl<'policy> TextInputControl<'policy> {
#[must_use]
pub const fn new(policy: &'policy TextInputPolicy) -> Self {
Self { policy }
}
#[must_use]
pub const fn policy(&self) -> &TextInputPolicy {
self.policy
}
#[must_use]
pub fn visible_rows_for_width(&self, state: &TextInputState, width: u16) -> u16 {
let wrapped_rows = state
.buffer
.wrapped_layout(usize::from(width.max(1)))
.lines
.len()
.max(1);
usize_to_u16_saturating(wrapped_rows)
.max(self.policy.viewport.min_rows.max(1))
.min(self.policy.viewport.max_rows.unwrap_or(u16::MAX))
}
pub fn handle_key(&self, state: &mut TextInputState, stroke: KeyStroke) -> TextInputOutcome {
if !self.policy.keyboard.enabled {
return TextInputOutcome::Ignored;
}
if let Some(outcome) = self.handle_enter(state, stroke) {
return outcome;
}
if self.policy.keyboard.selection_keys
&& let Some(motion) = selection_motion(stroke)
{
extend_selection(&mut state.buffer, state.content_area, motion);
state.sync_scroll_to_cursor(self.policy);
return TextInputOutcome::Edited;
}
if let Some(outcome) = self.handle_edge_key(state, stroke) {
return outcome;
}
let Some(command) = self.policy.keyboard.keymap.command_for_key(stroke) else {
return TextInputOutcome::Ignored;
};
state.buffer.apply_command(command);
state.sync_scroll_to_cursor(self.policy);
TextInputOutcome::Edited
}
pub fn handle_mouse(&self, state: &mut TextInputState, mouse: MouseEvent) -> TextInputOutcome {
if !self.policy.mouse.enabled {
return TextInputOutcome::Ignored;
}
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) if self.policy.mouse.click_to_cursor => {
self.handle_mouse_down(state, mouse)
}
MouseEventKind::Drag(MouseButton::Left) if self.policy.mouse.drag_selection => {
self.handle_mouse_drag(state, mouse)
}
MouseEventKind::Up(MouseButton::Left) if state.mouse_selection_active() => {
state.mouse_selection.active = SelectionGranularity::Disabled;
TextInputOutcome::Redraw
}
MouseEventKind::Down(
MouseButton::Left
| MouseButton::Right
| MouseButton::Middle
| MouseButton::Other(_),
)
| MouseEventKind::Up(_)
| MouseEventKind::Drag(_)
| MouseEventKind::Move
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => TextInputOutcome::Ignored,
}
}
fn handle_enter(
&self,
state: &mut TextInputState,
stroke: KeyStroke,
) -> Option<TextInputOutcome> {
if stroke.key != KeyCode::Enter {
return None;
}
let behavior = if stroke.modifiers.shift {
self.policy
.keyboard
.shift_enter
.unwrap_or(self.policy.keyboard.enter)
} else if stroke.modifiers.is_empty() {
self.policy.keyboard.enter
} else {
return None;
};
Some(apply_enter_behavior(state, self.policy, behavior))
}
fn handle_edge_key(
&self,
state: &TextInputState,
stroke: KeyStroke,
) -> Option<TextInputOutcome> {
if !stroke.modifiers.is_empty() {
return None;
}
let width = usize::from(state.content_area.width.max(1));
let layout = state.buffer.wrapped_layout(width);
match stroke.key {
KeyCode::Up if layout.cursor.row == 0 && self.policy.edge.up_at_first_row => {
Some(TextInputOutcome::EdgeUp)
}
KeyCode::Down
if layout.cursor.row.saturating_add(1) >= layout.lines.len()
&& self.policy.edge.down_at_last_row =>
{
Some(TextInputOutcome::EdgeDown)
}
KeyCode::Char(_)
| KeyCode::Enter
| KeyCode::Tab
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Escape
| KeyCode::Space
| KeyCode::Up
| KeyCode::Down
| KeyCode::Left
| KeyCode::Right
| KeyCode::Home
| KeyCode::End
| KeyCode::PageUp
| KeyCode::PageDown
| KeyCode::Insert
| KeyCode::F(_) => None,
}
}
fn handle_mouse_down(&self, state: &mut TextInputState, mouse: MouseEvent) -> TextInputOutcome {
let Some((row, col)) = mouse_wrapped_position(state, mouse) else {
state.mouse_selection.active = SelectionGranularity::Disabled;
return TextInputOutcome::Ignored;
};
let count = state
.mouse_selection
.click_count(mouse.position.x, mouse.position.y);
let granularity = self.policy.mouse.granularity_for_click_count(count);
state.mouse_selection.active = if self.policy.mouse.drag_selection {
granularity
} else {
SelectionGranularity::Disabled
};
apply_selection_granularity(&mut state.buffer, state.content_area, row, col, granularity);
state.sync_scroll_to_cursor(self.policy);
TextInputOutcome::Redraw
}
fn handle_mouse_drag(&self, state: &mut TextInputState, mouse: MouseEvent) -> TextInputOutcome {
let Some((row, col)) = mouse_wrapped_position(state, mouse) else {
return TextInputOutcome::Ignored;
};
extend_selection_to_granularity(
&mut state.buffer,
state.content_area,
row,
col,
state.mouse_selection.active,
);
state.sync_scroll_to_cursor(self.policy);
TextInputOutcome::Redraw
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextInputPolicy {
pub keyboard: KeyboardPolicy,
pub mouse: MousePolicy,
pub viewport: ViewportPolicy,
pub edge: EdgePolicy,
}
impl Default for TextInputPolicy {
fn default() -> Self {
Self::raw()
}
}
impl TextInputPolicy {
#[must_use]
pub const fn raw() -> Self {
Self {
keyboard: KeyboardPolicy::disabled(),
mouse: MousePolicy::disabled(),
viewport: ViewportPolicy::raw(),
edge: EdgePolicy::disabled(),
}
}
#[must_use]
pub const fn chat_composer() -> Self {
Self {
keyboard: KeyboardPolicy::chat_composer(),
mouse: MousePolicy::text_selection(),
viewport: ViewportPolicy {
auto_scroll_to_cursor: true,
min_rows: 1,
max_rows: Some(6),
},
edge: EdgePolicy {
up_at_first_row: true,
down_at_last_row: true,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyboardPolicy {
pub enabled: bool,
pub keymap: TextKeymap,
pub enter: EnterBehavior,
pub shift_enter: Option<EnterBehavior>,
pub selection_keys: bool,
}
impl KeyboardPolicy {
#[must_use]
pub const fn disabled() -> Self {
Self {
enabled: false,
keymap: TextKeymap {
profile: bmux_text_edit::keyboard::TextInputProfile::Readline,
boundary_policy: bmux_text_edit::TextBoundaryPolicy::Buffer,
},
enter: EnterBehavior::Ignore,
shift_enter: None,
selection_keys: false,
}
}
#[must_use]
pub const fn chat_composer() -> Self {
Self {
enabled: true,
keymap: TextKeymap {
profile: bmux_text_edit::keyboard::TextInputProfile::Readline,
boundary_policy: bmux_text_edit::TextBoundaryPolicy::Buffer,
},
enter: EnterBehavior::Submit,
shift_enter: Some(EnterBehavior::InsertNewline),
selection_keys: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnterBehavior {
Ignore,
InsertNewline,
Submit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MousePolicy {
pub enabled: bool,
pub click_to_cursor: bool,
pub drag_selection: bool,
pub double_click: Option<SelectionGranularity>,
pub triple_click: Option<SelectionGranularity>,
}
impl MousePolicy {
#[must_use]
pub const fn disabled() -> Self {
Self {
enabled: false,
click_to_cursor: false,
drag_selection: false,
double_click: None,
triple_click: None,
}
}
#[must_use]
pub const fn text_selection() -> Self {
Self {
enabled: true,
click_to_cursor: true,
drag_selection: true,
double_click: Some(SelectionGranularity::Word),
triple_click: Some(SelectionGranularity::All),
}
}
const fn granularity_for_click_count(self, count: u8) -> SelectionGranularity {
match count {
3.. => option_granularity_or(self.triple_click, SelectionGranularity::Character),
2 => option_granularity_or(self.double_click, SelectionGranularity::Character),
_ => SelectionGranularity::Character,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ViewportPolicy {
pub auto_scroll_to_cursor: bool,
pub min_rows: u16,
pub max_rows: Option<u16>,
}
impl ViewportPolicy {
#[must_use]
pub const fn raw() -> Self {
Self {
auto_scroll_to_cursor: false,
min_rows: 1,
max_rows: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EdgePolicy {
pub up_at_first_row: bool,
pub down_at_last_row: bool,
}
impl EdgePolicy {
#[must_use]
pub const fn disabled() -> Self {
Self {
up_at_first_row: false,
down_at_last_row: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionGranularity {
Character,
Word,
All,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextInputOutcome {
Ignored,
Edited,
Redraw,
Submitted,
EdgeUp,
EdgeDown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MouseSelectionState {
last_click: Option<MouseClickState>,
active: SelectionGranularity,
}
impl Default for MouseSelectionState {
fn default() -> Self {
Self {
last_click: None,
active: SelectionGranularity::Disabled,
}
}
}
impl MouseSelectionState {
fn click_count(&mut self, x: u16, y: u16) -> u8 {
let now = Instant::now();
let count = self.last_click.map_or(1, |last| {
let near = last.x.abs_diff(x) <= DEFAULT_MULTI_CLICK_DISTANCE
&& last.y.abs_diff(y) <= DEFAULT_MULTI_CLICK_DISTANCE;
let quick = now.saturating_duration_since(last.at) <= DEFAULT_MULTI_CLICK_WINDOW;
if near && quick {
last.count.saturating_add(1)
} else {
1
}
});
let capped = count.min(3);
self.last_click = Some(MouseClickState {
x,
y,
at: now,
count: capped,
});
capped
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MouseClickState {
x: u16,
y: u16,
at: Instant,
count: u8,
}
const fn option_granularity_or(
value: Option<SelectionGranularity>,
fallback: SelectionGranularity,
) -> SelectionGranularity {
match value {
Some(value) => value,
None => fallback,
}
}
fn apply_enter_behavior(
state: &mut TextInputState,
policy: &TextInputPolicy,
behavior: EnterBehavior,
) -> TextInputOutcome {
match behavior {
EnterBehavior::Ignore => TextInputOutcome::Ignored,
EnterBehavior::InsertNewline => {
state.buffer.insert_newline();
state.sync_scroll_to_cursor(policy);
TextInputOutcome::Edited
}
EnterBehavior::Submit => TextInputOutcome::Submitted,
}
}
const fn selection_motion(stroke: KeyStroke) -> Option<TextMotion> {
let Modifiers {
ctrl,
alt,
shift,
super_key,
hyper,
meta,
} = stroke.modifiers;
if !shift || super_key || hyper || meta {
return None;
}
match stroke.key {
KeyCode::Left if ctrl || alt => Some(TextMotion::WordLeft),
KeyCode::Right if ctrl || alt => Some(TextMotion::WordRight),
KeyCode::Left => Some(TextMotion::Left),
KeyCode::Right => Some(TextMotion::Right),
KeyCode::Up => Some(TextMotion::VisualUp),
KeyCode::Down => Some(TextMotion::VisualDown),
KeyCode::Char(_)
| KeyCode::Enter
| KeyCode::Tab
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Escape
| KeyCode::Space
| KeyCode::Home
| KeyCode::End
| KeyCode::PageUp
| KeyCode::PageDown
| KeyCode::Insert
| KeyCode::F(_) => None,
}
}
fn extend_selection(buffer: &mut TextEditBuffer, area: Rect, motion: TextMotion) {
match motion {
TextMotion::VisualUp => extend_visual_selection(buffer, area, -1),
TextMotion::VisualDown => extend_visual_selection(buffer, area, 1),
motion => buffer.move_cursor_with_selection(motion, SelectionMode::Extend),
}
}
fn extend_visual_selection(buffer: &mut TextEditBuffer, area: Rect, delta: isize) {
let width = usize::from(area.width.max(1));
let layout = buffer.wrapped_layout(width);
let target_row = if delta.is_negative() {
layout.cursor.row.saturating_sub(delta.unsigned_abs())
} else {
layout
.cursor
.row
.saturating_add(delta.unsigned_abs())
.min(layout.lines.len().saturating_sub(1))
};
buffer.select_to_wrapped_position(width, target_row, layout.cursor.col);
}
fn cursor_scroll_offset(buffer: &TextEditBuffer, area: Rect) -> usize {
if area.height == 0 {
return 0;
}
let layout = buffer.wrapped_layout(usize::from(area.width.max(1)));
layout
.cursor
.row
.saturating_add(1)
.saturating_sub(usize::from(area.height))
}
fn mouse_wrapped_position(state: &TextInputState, mouse: MouseEvent) -> Option<(usize, usize)> {
let area = state.content_area;
if mouse.position.y < area.y || mouse.position.y >= area.bottom() {
return None;
}
if mouse.position.x < area.x || mouse.position.x >= area.right() {
return None;
}
Some((
usize::from(mouse.position.y.saturating_sub(area.y)).saturating_add(state.vertical_scroll),
usize::from(mouse.position.x.saturating_sub(area.x)),
))
}
fn apply_selection_granularity(
buffer: &mut TextEditBuffer,
area: Rect,
row: usize,
col: usize,
granularity: SelectionGranularity,
) {
let width = usize::from(area.width.max(1));
let byte_index = buffer.byte_index_for_wrapped_position(width, row, col);
match granularity {
SelectionGranularity::Character | SelectionGranularity::Disabled => {
buffer.move_cursor(TextMotion::Absolute(byte_index));
}
SelectionGranularity::Word => select_word_at(buffer, byte_index),
SelectionGranularity::All => buffer.select_all(),
}
}
fn extend_selection_to_granularity(
buffer: &mut TextEditBuffer,
area: Rect,
row: usize,
col: usize,
granularity: SelectionGranularity,
) {
let width = usize::from(area.width.max(1));
let byte_index = buffer.byte_index_for_wrapped_position(width, row, col);
match granularity {
SelectionGranularity::Character => {
buffer.move_cursor_with_selection(
TextMotion::Absolute(byte_index),
SelectionMode::Extend,
);
}
SelectionGranularity::Word => {
let target =
word_range_at(buffer.text(), byte_index).map_or(byte_index, |(_, end)| end);
buffer.move_cursor_with_selection(TextMotion::Absolute(target), SelectionMode::Extend);
}
SelectionGranularity::All => buffer.select_all(),
SelectionGranularity::Disabled => {}
}
}
fn select_word_at(buffer: &mut TextEditBuffer, byte_index: usize) {
if let Some((start, end)) = word_range_at(buffer.text(), byte_index) {
buffer.move_cursor(TextMotion::Absolute(start));
buffer.move_cursor_with_selection(TextMotion::Absolute(end), SelectionMode::Extend);
} else {
buffer.move_cursor(TextMotion::Absolute(byte_index));
}
}
fn word_range_at(text: &str, byte_index: usize) -> Option<(usize, usize)> {
if text.is_empty() {
return None;
}
let index = byte_index.min(text.len());
let ranges = text
.grapheme_indices(true)
.map(|(start, grapheme)| (start, start.saturating_add(grapheme.len()), grapheme))
.collect::<Vec<_>>();
if ranges.is_empty() {
return None;
}
let mut position = ranges
.iter()
.position(|(start, end, _)| index >= *start && index < *end)
.unwrap_or_else(|| ranges.len().saturating_sub(1));
if ranges[position].2.chars().all(char::is_whitespace) {
position = ranges
.iter()
.enumerate()
.skip(position)
.find(|(_, (_, _, grapheme))| !grapheme.chars().all(char::is_whitespace))
.map_or(position, |(index, _)| index);
}
if ranges[position].2.chars().all(char::is_whitespace) {
return None;
}
let mut start_position = position;
while start_position > 0 && is_word_grapheme(ranges[start_position - 1].2) {
start_position -= 1;
}
let mut end_position = position;
while end_position + 1 < ranges.len() && is_word_grapheme(ranges[end_position + 1].2) {
end_position += 1;
}
Some((ranges[start_position].0, ranges[end_position].1))
}
fn is_word_grapheme(grapheme: &str) -> bool {
grapheme.chars().any(|ch| ch.is_alphanumeric() || ch == '_')
}
fn usize_to_u16_saturating(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use bmux_tui::geometry::Point;
fn key(key: KeyCode) -> KeyStroke {
KeyStroke::simple(key)
}
fn shift_key(key: KeyCode) -> KeyStroke {
KeyStroke::with_modifiers(
key,
Modifiers {
shift: true,
..Modifiers::NONE
},
)
}
fn mouse(kind: MouseEventKind, x: u16, y: u16) -> MouseEvent {
MouseEvent::new(kind, Point::new(x, y))
}
#[test]
fn raw_policy_ignores_keyboard_and_mouse() {
let policy = TextInputPolicy::raw();
let control = TextInputControl::new(&policy);
let mut state = TextInputState::new(TextEditBuffer::from_text("hello"));
state.set_content_area(Rect::new(0, 0, 20, 1), &policy);
assert_eq!(
control.handle_key(&mut state, key(KeyCode::Left)),
TextInputOutcome::Ignored
);
assert_eq!(
control.handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 1, 0)
),
TextInputOutcome::Ignored
);
assert_eq!(state.buffer().cursor_byte_index(), "hello".len());
}
#[test]
fn shift_selection_extends_buffer_selection() {
let policy = TextInputPolicy::chat_composer();
let control = TextInputControl::new(&policy);
let mut state = TextInputState::new(TextEditBuffer::from_text("hello"));
state.set_content_area(Rect::new(0, 0, 20, 1), &policy);
assert_eq!(
control.handle_key(&mut state, shift_key(KeyCode::Left)),
TextInputOutcome::Edited
);
assert_eq!(state.buffer().selected_text(), Some("o".to_string()));
}
#[test]
fn edge_keys_emit_history_outcomes() {
let policy = TextInputPolicy::chat_composer();
let control = TextInputControl::new(&policy);
let mut state = TextInputState::new(TextEditBuffer::from_text("hello"));
state.set_content_area(Rect::new(0, 0, 20, 1), &policy);
assert_eq!(
control.handle_key(&mut state, key(KeyCode::Down)),
TextInputOutcome::EdgeDown
);
state.buffer_mut().move_cursor(TextMotion::Start);
assert_eq!(
control.handle_key(&mut state, key(KeyCode::Up)),
TextInputOutcome::EdgeUp
);
}
#[test]
fn double_click_selects_word_and_triple_click_selects_all() {
let policy = TextInputPolicy::chat_composer();
let control = TextInputControl::new(&policy);
let mut state = TextInputState::new(TextEditBuffer::from_text("hello world"));
state.set_content_area(Rect::new(0, 0, 20, 1), &policy);
let _ = control.handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 1, 0),
);
let _ = control.handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 1, 0),
);
assert_eq!(state.buffer().selected_text(), Some("hello".to_string()));
let _ = control.handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 1, 0),
);
assert_eq!(
state.buffer().selected_text(),
Some("hello world".to_string())
);
}
#[test]
fn drag_extends_selection() {
let policy = TextInputPolicy::chat_composer();
let control = TextInputControl::new(&policy);
let mut state = TextInputState::new(TextEditBuffer::from_text("hello world"));
state.set_content_area(Rect::new(0, 0, 20, 1), &policy);
let _ = control.handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
);
let _ = control.handle_mouse(
&mut state,
mouse(MouseEventKind::Drag(MouseButton::Left), 5, 0),
);
assert_eq!(state.buffer().selected_text(), Some("hello".to_string()));
}
}