use std::cell::Cell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use teksilo_core::signal::Signal;
const CARET_BLINK_INTERVAL: f32 = 0.5;
const DEBOUNCE_WINDOW_SECS: f32 = 0.150;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaretPolicy {
Blinking,
StaticVisible,
Hidden,
}
pub trait EditorCommand: Copy {
fn mutates_document(&self) -> bool;
fn is_regressive(&self) -> bool;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandFilter {
All,
ReadOnly,
ForwardOnly,
}
impl CommandFilter {
pub fn accepts<C: EditorCommand>(&self, cmd: C) -> bool {
match self {
Self::All => true,
Self::ReadOnly => !cmd.mutates_document(),
Self::ForwardOnly => !cmd.is_regressive(),
}
}
pub fn collapses_selection_before_insert(&self) -> bool {
matches!(self, Self::ForwardOnly)
}
pub fn allows_wholesale_replacement(&self) -> bool {
matches!(self, Self::All)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessibilityRole {
Editor,
Document,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardPolicy {
Full,
CopyAndSelectAllOnly,
}
impl ClipboardPolicy {
pub fn allows_cut(&self) -> bool {
matches!(self, Self::Full)
}
pub fn allows_paste(&self) -> bool {
matches!(self, Self::Full)
}
pub fn allows_paste_unformatted(&self) -> bool {
matches!(self, Self::Full)
}
pub fn allows_copy(&self) -> bool {
true
}
}
#[derive(Debug, Clone, Copy)]
pub struct PolicyBundle {
pub command_filter: CommandFilter,
pub caret_policy: CaretPolicy,
pub access_role: AccessibilityRole,
pub clipboard_policy: ClipboardPolicy,
}
impl PolicyBundle {
pub const fn is_read_only(&self) -> bool {
matches!(self.access_role, AccessibilityRole::Document)
}
pub const fn with_command_filter(self, command_filter: CommandFilter) -> Self {
Self {
command_filter,
..self
}
}
}
pub const EDITOR_PRESET: PolicyBundle = PolicyBundle {
command_filter: CommandFilter::All,
caret_policy: CaretPolicy::Blinking,
access_role: AccessibilityRole::Editor,
clipboard_policy: ClipboardPolicy::Full,
};
pub const READ_ONLY_PRESET: PolicyBundle = PolicyBundle {
command_filter: CommandFilter::ReadOnly,
caret_policy: CaretPolicy::Hidden,
access_role: AccessibilityRole::Document,
clipboard_policy: ClipboardPolicy::CopyAndSelectAllOnly,
};
#[derive(Debug, Default)]
pub(crate) struct CaretBlink {
last_toggle: Option<Instant>,
}
impl CaretBlink {
pub(crate) fn new() -> Self {
Self { last_toggle: None }
}
pub(crate) fn restart(&mut self) {
self.last_toggle = Some(Instant::now());
}
pub(crate) fn reset(&mut self) {
self.last_toggle = None;
}
pub(crate) fn tick(
&mut self,
policy: CaretPolicy,
active: bool,
caret_visible: &Signal<bool>,
wake_at: Option<&Rc<Cell<Option<Instant>>>>,
) {
let blinking = active && policy == CaretPolicy::Blinking;
if blinking {
let now = Instant::now();
let interval = Duration::from_secs_f32(CARET_BLINK_INTERVAL);
match self.last_toggle {
None => self.last_toggle = Some(now),
Some(last) if now.saturating_duration_since(last) >= interval => {
self.last_toggle = Some(now);
let was = caret_visible.get();
caret_visible.set(!was);
}
_ => {}
}
if let (Some(last), Some(wake)) = (self.last_toggle, wake_at) {
let next = last + interval;
let merged = match wake.get() {
Some(existing) if existing <= next => existing,
_ => next,
};
wake.set(Some(merged));
}
return;
}
self.last_toggle = None;
match policy {
CaretPolicy::Blinking => {
if caret_visible.get() {
caret_visible.set(false);
}
}
CaretPolicy::StaticVisible => {
if caret_visible.get() != active {
caret_visible.set(active);
}
}
CaretPolicy::Hidden => {}
}
}
}
#[derive(Debug)]
pub(crate) struct Debounce {
timer: f32,
}
impl Default for Debounce {
fn default() -> Self {
Self::new()
}
}
impl Debounce {
pub(crate) fn new() -> Self {
Self { timer: 1.0 }
}
pub(crate) fn tick(&mut self, delta: f32) -> bool {
self.timer += delta;
if self.timer >= DEBOUNCE_WINDOW_SECS {
self.timer = 0.0;
return true;
}
false
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct ScrollMetrics {
pub max_x: f32,
pub max_y: f32,
pub ratio_x: f32,
pub ratio_y: f32,
}
impl ScrollMetrics {
pub(crate) fn compute(
content_height: f32,
max_content_width: f32,
viewport_width: f32,
viewport_height: f32,
) -> Self {
Self {
max_x: (max_content_width - viewport_width).max(0.0),
max_y: (content_height - viewport_height).max(0.0),
ratio_x: if max_content_width > 0.0 && viewport_width > 0.0 {
(viewport_width / max_content_width).clamp(0.0, 1.0)
} else {
1.0
},
ratio_y: if content_height > 0.0 && viewport_height > 0.0 {
(viewport_height / content_height).clamp(0.0, 1.0)
} else {
1.0
},
}
}
pub(crate) fn publish(
&self,
scroll_x: &Signal<f32>,
scroll_y: &Signal<f32>,
max_scroll_x: &Signal<f32>,
max_scroll_y: &Signal<f32>,
viewport_ratio_x: &Signal<f32>,
viewport_ratio_y: &Signal<f32>,
) {
max_scroll_x.set_if_changed(self.max_x);
max_scroll_y.set_if_changed(self.max_y);
viewport_ratio_x.set_if_changed(self.ratio_x);
viewport_ratio_y.set_if_changed(self.ratio_y);
scroll_x.set_if_changed(scroll_x.get().clamp(0.0, self.max_x));
scroll_y.set_if_changed(scroll_y.get().clamp(0.0, self.max_y));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn wake_slot() -> Rc<Cell<Option<Instant>>> {
Rc::new(Cell::new(None))
}
#[test]
fn blink_hides_caret_when_not_active() {
let mut blink = CaretBlink::new();
let visible = Signal::new(true);
blink.tick(CaretPolicy::Blinking, false, &visible, None);
assert!(!visible.get(), "an unfocused caret must not be drawn");
}
#[test]
fn blink_does_not_toggle_before_the_interval() {
let mut blink = CaretBlink::new();
let visible = Signal::new(true);
blink.tick(CaretPolicy::Blinking, true, &visible, None);
blink.tick(CaretPolicy::Blinking, true, &visible, None);
assert!(visible.get(), "caret must not toggle within the interval");
}
#[test]
fn blink_toggles_once_the_interval_has_elapsed() {
let mut blink = CaretBlink::new();
let visible = Signal::new(true);
blink.last_toggle =
Some(Instant::now() - Duration::from_secs_f32(CARET_BLINK_INTERVAL + 0.01));
blink.tick(CaretPolicy::Blinking, true, &visible, None);
assert!(!visible.get(), "caret must toggle after the interval");
}
#[test]
fn blink_schedules_a_wake_up_so_the_loop_can_idle() {
let mut blink = CaretBlink::new();
let visible = Signal::new(true);
let wake = wake_slot();
blink.tick(CaretPolicy::Blinking, true, &visible, Some(&wake));
assert!(
wake.get().is_some(),
"a blinking caret must schedule its next toggle, else the event \
loop has to poll at max rate to catch it"
);
}
#[test]
fn blink_never_delays_an_earlier_pending_wake_up() {
let mut blink = CaretBlink::new();
let visible = Signal::new(true);
let wake = wake_slot();
let sooner = Instant::now() + Duration::from_millis(10);
wake.set(Some(sooner));
blink.tick(CaretPolicy::Blinking, true, &visible, Some(&wake));
assert_eq!(
wake.get(),
Some(sooner),
"another subsystem's earlier wake-up must survive — pushing it \
out to our toggle would stall whatever needed it"
);
}
#[test]
fn hidden_policy_never_shows_the_caret() {
let mut blink = CaretBlink::new();
let visible = Signal::new(false);
blink.tick(CaretPolicy::Hidden, true, &visible, None);
assert!(
!visible.get(),
"a hidden caret must stay hidden when focused"
);
}
#[test]
fn static_visible_tracks_activity_without_blinking() {
let mut blink = CaretBlink::new();
let visible = Signal::new(false);
blink.tick(CaretPolicy::StaticVisible, true, &visible, None);
assert!(visible.get(), "a static caret shows while active");
blink.tick(CaretPolicy::StaticVisible, false, &visible, None);
assert!(!visible.get(), "a static caret hides when inactive");
}
#[test]
fn restart_delays_the_next_toggle_by_a_full_interval() {
let mut blink = CaretBlink::new();
let visible = Signal::new(true);
blink.last_toggle =
Some(Instant::now() - Duration::from_secs_f32(CARET_BLINK_INTERVAL + 0.01));
blink.restart();
blink.tick(CaretPolicy::Blinking, true, &visible, None);
assert!(
visible.get(),
"restart must push the pending toggle out by a full interval, else \
the caret blinks off mid-keystroke"
);
}
#[test]
fn restart_does_not_itself_show_the_caret() {
let mut blink = CaretBlink::new();
let visible = Signal::new(false);
blink.restart();
blink.tick(CaretPolicy::Blinking, true, &visible, None);
assert!(
!visible.get(),
"restart seeds the phase only — showing the caret is the caller's"
);
}
#[test]
fn debounce_starts_expired_so_initial_state_publishes_at_once() {
let mut d = Debounce::new();
assert!(
d.tick(0.0),
"a freshly built toolbar must not wait a window to show correct \
undo/redo state"
);
}
#[test]
fn debounce_coalesces_within_the_window() {
let mut d = Debounce::new();
assert!(d.tick(0.0));
assert!(!d.tick(0.05));
assert!(!d.tick(0.05));
assert!(d.tick(0.05), "0.15s total must close the window");
}
#[test]
fn scroll_metrics_report_no_overflow_when_content_fits() {
let m = ScrollMetrics::compute(50.0, 80.0, 100.0, 100.0);
assert_eq!(m.max_x, 0.0);
assert_eq!(m.max_y, 0.0);
assert_eq!(m.ratio_x, 1.0);
assert_eq!(m.ratio_y, 1.0);
}
#[test]
fn scroll_metrics_report_overflow_when_content_exceeds_viewport() {
let m = ScrollMetrics::compute(200.0, 200.0, 100.0, 100.0);
assert_eq!(m.max_y, 100.0);
assert!((m.ratio_y - 0.5).abs() < 1e-6);
}
#[test]
fn scroll_metrics_ratio_is_full_on_an_empty_document() {
let m = ScrollMetrics::compute(0.0, 0.0, 100.0, 100.0);
assert_eq!(
m.ratio_y, 1.0,
"an empty document must show a full thumb, not a zero-height one"
);
}
#[test]
fn publish_writes_every_limit_and_ratio_signal() {
let (sx, sy) = (Signal::new(0.0), Signal::new(0.0));
let (mx, my) = (Signal::new(0.0), Signal::new(0.0));
let (rx, ry) = (Signal::new(1.0), Signal::new(1.0));
let m = ScrollMetrics::compute(200.0, 400.0, 100.0, 100.0);
m.publish(&sx, &sy, &mx, &my, &rx, &ry);
assert_eq!(
mx.get(),
300.0,
"horizontal limit must reach the scroll bar"
);
assert_eq!(my.get(), 100.0, "vertical limit must reach the scroll bar");
assert!(
(rx.get() - 0.25).abs() < 1e-6,
"horizontal thumb ratio must reach the scroll bar, got {}",
rx.get()
);
assert!(
(ry.get() - 0.5).abs() < 1e-6,
"vertical thumb ratio must reach the scroll bar, got {}",
ry.get()
);
}
#[test]
fn publish_clamps_a_scroll_offset_left_past_the_new_end() {
let (sx, sy) = (Signal::new(0.0), Signal::new(500.0));
let (mx, my) = (Signal::new(0.0), Signal::new(500.0));
let (rx, ry) = (Signal::new(1.0), Signal::new(1.0));
let m = ScrollMetrics::compute(50.0, 50.0, 100.0, 100.0);
m.publish(&sx, &sy, &mx, &my, &rx, &ry);
assert_eq!(
sy.get(),
0.0,
"deleting text must not leave the view parked past the end"
);
}
}