use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use compact_str::CompactString;
use crate::tdom::node::NodeId;
mod cmd;
mod edit;
mod field;
mod focus;
mod key_info;
mod mouse;
mod page;
mod refresh;
mod scroll_anim;
mod select;
pub use cmd::{DirtyInfo, DrainedBatch, RenderCmd, SelectionMode};
pub use edit::{
apply_input_key, apply_input_word_key, is_input_field_key, next_word_boundary,
paste_into_field, prev_word_boundary, selection_range,
};
pub use field::{
KEY_GATE_TIMEOUT, KeyGate, apply_textarea_key, collect_input_ids, number_input_allows_char,
};
pub use focus::apply_focus_requests;
pub use key_info::key_event_info;
pub use mouse::{handle_click, handle_mouse_down, handle_mouse_move, handle_mouse_up};
pub use refresh::{default_wheel_scroll, trigger_refresh};
pub use scroll_anim::{
ANIM_POLL_MS, ScrollAnim, ScrollIntoViewBlock, ScrollTarget, active_scroll_target,
ease_out_cubic, scroll_anim_duration, scroll_into_view_target, step_target,
};
pub use select::{OverlayKind, SelectOpenState};
use field::clear_text_selection;
const SCROLL_LINES_PER_TICK: u32 = 3;
const SCROLLBAR_VISIBLE_FOR: Duration = Duration::from_millis(900);
pub type SharedScrollbarVisibility = Arc<Mutex<HashMap<NodeId, bool>>>;
const ZOOM_STEP: f32 = 0.1;
const ZOOM_MIN: f32 = 0.2;
const ZOOM_MAX: f32 = 5.0;
const TEXT_SEL_DRAG_THRESHOLD: u32 = 2;
#[derive(Debug)]
pub struct InputState {
pub scroll_y: u32,
pub scroll_offsets: HashMap<NodeId, u32>,
pub scroll_x_offsets: HashMap<NodeId, u32>,
pub scroll_anim: Option<ScrollAnim>,
pub scrollbars_visible: SharedScrollbarVisibility,
pub scrollbar_hide_at: HashMap<NodeId, Instant>,
pub zoom: f32,
pub pressed: Option<(u16, u16)>,
pub focused_input: Option<NodeId>,
pub focused_is_textarea: bool,
pub focused_is_range: bool,
pub input_value: CompactString,
pub input_cursor: usize,
pub input_sel_anchor: Option<usize>,
pub input_clipboard: String,
pub drag_selecting: bool,
press_screen: Option<(u16, u16)>,
drag_origin: Option<(u32, u32)>,
field_drag_started: bool,
pressed_target: Option<NodeId>,
pub scroll_drag: Option<(NodeId, u32)>,
pub scroll_drag_x: Option<u32>,
pub last_mouse: Option<(u16, u16)>,
pub resize_pending: bool,
pub pointer_revalidation: bool,
pub text_sel_anchor: Option<(u32, u32)>,
pub text_sel_end: Option<(u32, u32)>,
pub text_sel_mode: SelectionMode,
text_sel_armed: bool,
pub copy_flow: bool,
pub smooth_scroll: bool,
pub copy_block: bool,
pub selection_text: Arc<Mutex<String>>,
pub open_select: Option<SelectOpenState>,
pub next_key_id: u32,
pub key_gate: Option<KeyGate>,
pub wheel_gate: Option<KeyGate>,
pub pending_wheel: Option<(u32, u32, bool)>,
pub last_click: Option<(Instant, u32, u32)>,
pub ui: Option<Arc<crate::script::SharedUiState>>,
}
const DBLCLICK_MS: u128 = 500;
impl InputState {
pub fn new() -> Self {
Self {
scroll_y: 0,
scroll_offsets: HashMap::new(),
scroll_x_offsets: HashMap::new(),
scroll_anim: None,
scrollbars_visible: Arc::new(Mutex::new(HashMap::new())),
scrollbar_hide_at: HashMap::new(),
zoom: 1.0,
pressed: None,
focused_input: None,
focused_is_textarea: false,
focused_is_range: false,
input_value: CompactString::new(""),
input_cursor: 0,
input_sel_anchor: None,
input_clipboard: String::new(),
drag_selecting: false,
press_screen: None,
drag_origin: None,
field_drag_started: false,
pressed_target: None,
scroll_drag: None,
scroll_drag_x: None,
last_mouse: None,
resize_pending: false,
pointer_revalidation: false,
text_sel_anchor: None,
text_sel_end: None,
text_sel_mode: SelectionMode::Flow,
text_sel_armed: false,
copy_flow: true,
smooth_scroll: true,
copy_block: true,
selection_text: Arc::new(Mutex::new(String::new())),
open_select: None,
next_key_id: 0,
key_gate: None,
wheel_gate: None,
pending_wheel: None,
last_click: None,
ui: None,
}
}
pub fn show_scrollbar(&mut self, node_id: NodeId) {
if let Ok(mut visible) = self.scrollbars_visible.lock() {
visible.insert(node_id, true);
}
self.scrollbar_hide_at
.insert(node_id, Instant::now() + SCROLLBAR_VISIBLE_FOR);
}
pub fn take_expired_scrollbar_visibility(&mut self) -> Vec<NodeId> {
let now = Instant::now();
let expired: Vec<NodeId> = self
.scrollbar_hide_at
.iter()
.filter(|(_, deadline)| **deadline <= now)
.map(|(node_id, _)| *node_id)
.collect();
if expired.is_empty() {
return expired;
}
for node_id in &expired {
self.scrollbar_hide_at.remove(node_id);
}
if let Ok(mut visible) = self.scrollbars_visible.lock() {
for node_id in &expired {
visible.insert(*node_id, false);
}
}
expired
}
pub fn hide_scrollbars(&mut self) {
self.scrollbar_hide_at.clear();
if let Ok(mut visible) = self.scrollbars_visible.lock() {
visible.clear();
}
}
pub fn invalidate_viewport(&mut self, cmds: &mut Vec<RenderCmd>) {
self.clear_press_tracking();
if let Some(anim) = &self.scroll_anim
&& matches!(anim.target(), ScrollTarget::Container(_))
{
self.scroll_anim = None;
}
clear_text_selection(self, cmds);
if let Some(open) = self.open_select.take() {
cmds.push(RenderCmd::SelectClose {
node_id: open.node_id,
});
}
}
pub fn reset_document_state(&mut self, cmds: &mut Vec<RenderCmd>) {
self.clear_press_tracking();
self.scroll_offsets.clear();
self.scroll_anim = None;
self.hide_scrollbars();
self.focused_input = None;
self.focused_is_textarea = false;
self.focused_is_range = false;
self.input_value = CompactString::new("");
self.input_cursor = 0;
self.input_sel_anchor = None;
clear_text_selection(self, cmds);
if let Some(open) = self.open_select.take() {
cmds.push(RenderCmd::SelectClose {
node_id: open.node_id,
});
}
self.request_resize_revalidation();
}
fn clear_press_tracking(&mut self) {
self.pressed = None;
self.drag_selecting = false;
self.press_screen = None;
self.drag_origin = None;
self.field_drag_started = false;
self.pressed_target = None;
self.scroll_drag = None;
}
pub fn drop_stale_press(&mut self) -> bool {
if self.pressed.take().is_some() {
self.clear_press_tracking();
true
} else {
false
}
}
pub fn request_resize_revalidation(&mut self) {
self.resize_pending = true;
}
pub fn take_resize_revalidation(&mut self) -> bool {
std::mem::take(&mut self.resize_pending)
}
pub fn request_pointer_revalidation(&mut self) {
self.pointer_revalidation = true;
}
pub fn take_pointer_revalidation(&mut self) -> bool {
std::mem::take(&mut self.pointer_revalidation)
}
pub fn clamp_scroll_to(&mut self, max_scroll: u32) {
if self.scroll_y > max_scroll {
self.scroll_y = max_scroll;
}
}
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
#[cfg(windows)]
pub(crate) fn reapply_console_modes() {
if let Err(e) = crossterm::terminal::enable_raw_mode() {
log::error!("Failed to re-enable raw mode after resize: {}", e);
}
if let Err(e) = crossterm::execute!(
std::io::sink(),
crossterm::event::EnableMouseCapture,
crossterm::event::EnableBracketedPaste
) {
log::error!("Failed to re-enable input modes after resize: {}", e);
}
}
#[cfg(not(windows))]
pub(crate) fn reapply_console_modes() {}
#[cfg(test)]
mod scrollbar_visibility_tests {
use slotmap::Key;
use std::time::{Duration, Instant};
use super::{InputState, NodeId};
#[test]
fn expired_scrollbar_visibility_transitions_once() {
let mut state = InputState::new();
let node_id = NodeId::null();
state.show_scrollbar(node_id);
assert_eq!(
state.scrollbars_visible.lock().unwrap().get(&node_id),
Some(&true)
);
state
.scrollbar_hide_at
.insert(node_id, Instant::now() - Duration::from_millis(1));
assert_eq!(state.take_expired_scrollbar_visibility(), vec![node_id]);
assert_eq!(
state.scrollbars_visible.lock().unwrap().get(&node_id),
Some(&false)
);
assert!(state.take_expired_scrollbar_visibility().is_empty());
}
}