From 80d7f537961efacfcefa31a360a2227329456175 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 19 May 2026 09:49:27 +0200 Subject: [PATCH] refactor: deepen toolbar slider model --- src/backend/wayland/toolbar/events.rs | 14 - src/backend/wayland/toolbar/hit.rs | 293 +++++++++++++----- .../wayland/toolbar/layout/side/mod.rs | 3 +- src/backend/wayland/toolbar/mod.rs | 2 +- .../toolbar/render/side_palette/marker.rs | 9 +- .../render/side_palette/step/custom_rows.rs | 10 +- .../render/side_palette/step/delay_sliders.rs | 17 +- .../toolbar/render/side_palette/text.rs | 4 +- .../toolbar/render/side_palette/thickness.rs | 8 +- src/ui/toolbar/model/activation.rs | 154 +++++++++ src/ui/toolbar/model/mod.rs | 2 +- 11 files changed, 400 insertions(+), 116 deletions(-) diff --git a/src/backend/wayland/toolbar/events.rs b/src/backend/wayland/toolbar/events.rs index c05b3848..060e2a93 100644 --- a/src/backend/wayland/toolbar/events.rs +++ b/src/backend/wayland/toolbar/events.rs @@ -1,5 +1,4 @@ use crate::draw::Color; -use crate::ui::toolbar::model::ToolbarSliderSpec; /// Kinds of hit regions and their drag semantics. #[derive(Clone, Debug, PartialEq)] @@ -49,19 +48,6 @@ impl HitKind { } } -/// Convert normalized drag position [0,1] to a delay in seconds. -pub fn delay_secs_from_t(t: f64) -> f64 { - let spec = ToolbarSliderSpec::DELAY_SECONDS; - spec.min + t.clamp(0.0, 1.0) * (spec.max - spec.min) -} - -/// Convert a delay in ms to normalized [0,1] position for sliders. -pub fn delay_t_from_ms(delay_ms: u64) -> f64 { - let spec = ToolbarSliderSpec::DELAY_SECONDS; - let delay_s = (delay_ms as f64 / 1000.0).clamp(spec.min, spec.max); - (delay_s - spec.min) / (spec.max - spec.min) -} - /// Convert HSV to RGB for color picker math. pub fn hsv_to_rgb(h: f64, s: f64, v: f64) -> Color { let h = (h - h.floor()).clamp(0.0, 1.0) * 6.0; diff --git a/src/backend/wayland/toolbar/hit.rs b/src/backend/wayland/toolbar/hit.rs index d1e06ecd..4479d422 100644 --- a/src/backend/wayland/toolbar/hit.rs +++ b/src/backend/wayland/toolbar/hit.rs @@ -2,7 +2,7 @@ use crate::backend::wayland::state::{color_log, debug_toolbar_color_logging_enab use crate::backend::wayland::toolbar::events::HitKind; use crate::backend::wayland::toolbar_intent::ToolbarIntent; use crate::ui::toolbar::ToolbarEvent; -use crate::ui::toolbar::model::ToolbarSliderSpec; +use crate::ui::toolbar::model::{ToolbarSlider, ToolbarSliderSpec, ToolbarSliderTarget}; #[derive(Clone, Debug)] pub struct HitRegion { @@ -43,22 +43,32 @@ pub fn intent_for_hit(hit: &HitRegion, x: f64, y: f64) -> Option<(ToolbarIntent, use crate::backend::wayland::toolbar::events::HitKind::*; use crate::ui::toolbar::ToolbarEvent; let event = match hit.kind { - DragSetThickness { min, max } => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - let value = min + t * (max - min); - ToolbarEvent::SetThickness(value) - } - DragSetMarkerOpacity { min, max } => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - let value = min + t * (max - min); - ToolbarEvent::SetMarkerOpacity(value) - } - DragSetFontSize => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - let spec = ToolbarSliderSpec::FONT_SIZE; - let value = spec.min + t * (spec.max - spec.min); - ToolbarEvent::SetFontSize(value) - } + DragSetThickness { min, max } => slider_event_for_hit( + ToolbarSliderTarget::Thickness, + ToolbarSliderSpec { + min, + max, + step: ToolbarSliderSpec::THICKNESS.step, + }, + hit, + x, + ), + DragSetMarkerOpacity { min, max } => slider_event_for_hit( + ToolbarSliderTarget::MarkerOpacity, + ToolbarSliderSpec { + min, + max, + step: ToolbarSliderSpec::MARKER_OPACITY.step, + }, + hit, + x, + ), + DragSetFontSize => slider_event_for_hit( + ToolbarSliderTarget::FontSize, + ToolbarSliderSpec::FONT_SIZE, + hit, + x, + ), PickColor { x: px, y: py, w, h } => { let hue = ((x - px) / w).clamp(0.0, 1.0); let value = (1.0 - (y - py) / h).clamp(0.0, 1.0); @@ -71,30 +81,30 @@ pub fn intent_for_hit(hit: &HitRegion, x: f64, y: f64) -> Option<(ToolbarIntent, } ToolbarEvent::SetColor(color) } - DragUndoDelay => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - ToolbarEvent::SetUndoDelay(crate::backend::wayland::toolbar::events::delay_secs_from_t( - t, - )) - } - DragRedoDelay => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - ToolbarEvent::SetRedoDelay(crate::backend::wayland::toolbar::events::delay_secs_from_t( - t, - )) - } - DragCustomUndoDelay => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - ToolbarEvent::SetCustomUndoDelay( - crate::backend::wayland::toolbar::events::delay_secs_from_t(t), - ) - } - DragCustomRedoDelay => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - ToolbarEvent::SetCustomRedoDelay( - crate::backend::wayland::toolbar::events::delay_secs_from_t(t), - ) - } + DragUndoDelay => slider_event_for_hit( + ToolbarSliderTarget::UndoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ), + DragRedoDelay => slider_event_for_hit( + ToolbarSliderTarget::RedoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ), + DragCustomUndoDelay => slider_event_for_hit( + ToolbarSliderTarget::CustomUndoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ), + DragCustomRedoDelay => slider_event_for_hit( + ToolbarSliderTarget::CustomRedoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ), DragMoveTop => ToolbarEvent::MoveTopToolbar { x, y }, DragMoveSide => ToolbarEvent::MoveSideToolbar { x, y }, crate::backend::wayland::toolbar::events::HitKind::Click => hit.event.clone(), @@ -111,22 +121,32 @@ pub fn drag_intent_for_hit(hit: &HitRegion, x: f64, y: f64) -> Option { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - let value = min + t * (max - min); - Some(ToolbarIntent(ToolbarEvent::SetThickness(value))) - } - DragSetMarkerOpacity { min, max } => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - let value = min + t * (max - min); - Some(ToolbarIntent(ToolbarEvent::SetMarkerOpacity(value))) - } - DragSetFontSize => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - let spec = ToolbarSliderSpec::FONT_SIZE; - let value = spec.min + t * (spec.max - spec.min); - Some(ToolbarIntent(ToolbarEvent::SetFontSize(value))) - } + DragSetThickness { min, max } => Some(ToolbarIntent(slider_event_for_hit( + ToolbarSliderTarget::Thickness, + ToolbarSliderSpec { + min, + max, + step: ToolbarSliderSpec::THICKNESS.step, + }, + hit, + x, + ))), + DragSetMarkerOpacity { min, max } => Some(ToolbarIntent(slider_event_for_hit( + ToolbarSliderTarget::MarkerOpacity, + ToolbarSliderSpec { + min, + max, + step: ToolbarSliderSpec::MARKER_OPACITY.step, + }, + hit, + x, + ))), + DragSetFontSize => Some(ToolbarIntent(slider_event_for_hit( + ToolbarSliderTarget::FontSize, + ToolbarSliderSpec::FONT_SIZE, + hit, + x, + ))), PickColor { x: px, y: py, w, h } => { let hue = ((x - px) / w).clamp(0.0, 1.0); let value = (1.0 - (y - py) / h).clamp(0.0, 1.0); @@ -139,36 +159,50 @@ pub fn drag_intent_for_hit(hit: &HitRegion, x: f64, y: f64) -> Option { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - Some(ToolbarIntent(ToolbarEvent::SetUndoDelay( - crate::backend::wayland::toolbar::events::delay_secs_from_t(t), - ))) - } - DragRedoDelay => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - Some(ToolbarIntent(ToolbarEvent::SetRedoDelay( - crate::backend::wayland::toolbar::events::delay_secs_from_t(t), - ))) - } - DragCustomUndoDelay => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - Some(ToolbarIntent(ToolbarEvent::SetCustomUndoDelay( - crate::backend::wayland::toolbar::events::delay_secs_from_t(t), - ))) - } - DragCustomRedoDelay => { - let t = ((x - hit.rect.0) / hit.rect.2).clamp(0.0, 1.0); - Some(ToolbarIntent(ToolbarEvent::SetCustomRedoDelay( - crate::backend::wayland::toolbar::events::delay_secs_from_t(t), - ))) - } + DragUndoDelay => Some(ToolbarIntent(slider_event_for_hit( + ToolbarSliderTarget::UndoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ))), + DragRedoDelay => Some(ToolbarIntent(slider_event_for_hit( + ToolbarSliderTarget::RedoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ))), + DragCustomUndoDelay => Some(ToolbarIntent(slider_event_for_hit( + ToolbarSliderTarget::CustomUndoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ))), + DragCustomRedoDelay => Some(ToolbarIntent(slider_event_for_hit( + ToolbarSliderTarget::CustomRedoDelay, + ToolbarSliderSpec::DELAY_SECONDS, + hit, + x, + ))), DragMoveTop => Some(ToolbarIntent(ToolbarEvent::MoveTopToolbar { x, y })), DragMoveSide => Some(ToolbarIntent(ToolbarEvent::MoveSideToolbar { x, y })), _ => None, } } +fn slider_event_for_hit( + target: ToolbarSliderTarget, + spec: ToolbarSliderSpec, + hit: &HitRegion, + pointer_x: f64, +) -> ToolbarEvent { + ToolbarSlider { + target, + spec, + value: spec.min, + } + .event_for_pointer_x(pointer_x, hit.rect.0, hit.rect.2) +} + fn focusable_indices(hits: &[HitRegion]) -> Vec { hits.iter() .enumerate() @@ -206,3 +240,100 @@ pub fn focused_event(hits: &[HitRegion], focus: Option) -> Option HitRegion { + HitRegion { + rect: (10.0, 20.0, 30.0, 40.0), + event, + kind: HitKind::Click, + tooltip: None, + } + } + + fn thickness_slider() -> HitRegion { + HitRegion { + rect: (100.0, 0.0, 200.0, 20.0), + event: ToolbarEvent::SetThickness(1.0), + kind: HitKind::DragSetThickness { + min: 10.0, + max: 20.0, + }, + tooltip: None, + } + } + + fn assert_set_thickness(event: ToolbarEvent, expected: f64) { + match event { + ToolbarEvent::SetThickness(value) => { + assert!( + (value - expected).abs() < 0.000_001, + "expected {expected}, got {value}" + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn slider_press_and_drag_use_same_pointer_mapping() { + let hit = thickness_slider(); + + let (press, start_drag) = intent_for_hit(&hit, 200.0, 10.0).expect("press intent"); + let drag = drag_intent_for_hit(&hit, 200.0, 10.0).expect("drag intent"); + + assert!(start_drag); + assert_set_thickness(press.0, 15.0); + assert_set_thickness(drag.0, 15.0); + } + + #[test] + fn slider_pointer_mapping_clamps_to_hit_rect() { + let hit = thickness_slider(); + + let (left, _) = intent_for_hit(&hit, 100.0, 10.0).expect("left intent"); + let (right, _) = intent_for_hit(&hit, 300.0, 10.0).expect("right intent"); + + assert_set_thickness(left.0, 10.0); + assert_set_thickness(right.0, 20.0); + assert!(intent_for_hit(&hit, 99.0, 10.0).is_none()); + assert!(drag_intent_for_hit(&hit, 301.0, 10.0).is_none()); + } + + #[test] + fn focus_traversal_uses_click_hits_only() { + let hits = vec![ + click(ToolbarEvent::Undo), + thickness_slider(), + click(ToolbarEvent::Redo), + ]; + + assert_eq!(next_focus_index(&hits, None, false), Some(0)); + assert_eq!(next_focus_index(&hits, Some(0), false), Some(2)); + assert_eq!(next_focus_index(&hits, Some(2), false), Some(0)); + assert_eq!(next_focus_index(&hits, None, true), Some(2)); + assert_eq!(next_focus_index(&hits, Some(2), true), Some(0)); + } + + #[test] + fn focused_event_returns_the_focused_hit_event() { + let hits = vec![ + click(ToolbarEvent::Undo), + thickness_slider(), + click(ToolbarEvent::Redo), + ]; + + assert!(matches!( + focused_event(&hits, Some(0)), + Some(ToolbarEvent::Undo) + )); + assert!(matches!( + focused_event(&hits, Some(2)), + Some(ToolbarEvent::Redo) + )); + assert!(focused_event(&hits, None).is_none()); + } +} diff --git a/src/backend/wayland/toolbar/layout/side/mod.rs b/src/backend/wayland/toolbar/layout/side/mod.rs index fa60cf24..70bc0fae 100644 --- a/src/backend/wayland/toolbar/layout/side/mod.rs +++ b/src/backend/wayland/toolbar/layout/side/mod.rs @@ -10,10 +10,11 @@ mod presets; mod settings; mod sliders; -pub(super) use super::super::events::{HitKind, delay_secs_from_t, delay_t_from_ms}; +pub(super) use super::super::events::HitKind; pub(super) use super::super::format_binding_label; pub(super) use super::super::hit::HitRegion; pub(super) use super::spec::ToolbarLayoutSpec; +pub(super) use crate::ui::toolbar::model::{delay_secs_from_t, delay_t_from_ms}; pub(super) use crate::ui::toolbar::snapshot::ToolContext; pub(super) use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; diff --git a/src/backend/wayland/toolbar/mod.rs b/src/backend/wayland/toolbar/mod.rs index c1de5d2a..56868fe6 100644 --- a/src/backend/wayland/toolbar/mod.rs +++ b/src/backend/wayland/toolbar/mod.rs @@ -7,7 +7,7 @@ mod rows; pub mod surfaces; #[allow(unused_imports)] -pub use events::{HitKind, ToolbarCursorHint, delay_secs_from_t, delay_t_from_ms, hsv_to_rgb}; +pub use events::{HitKind, ToolbarCursorHint, hsv_to_rgb}; #[allow(unused_imports)] pub use layout::{build_side_hits, build_top_hits, side_size, top_size}; pub use main::*; diff --git a/src/backend/wayland/toolbar/render/side_palette/marker.rs b/src/backend/wayland/toolbar/render/side_palette/marker.rs index 639cc472..5200dbc0 100644 --- a/src/backend/wayland/toolbar/render/side_palette/marker.rs +++ b/src/backend/wayland/toolbar/render/side_palette/marker.rs @@ -109,10 +109,7 @@ pub(super) fn draw_marker_opacity_section(layout: &mut SidePaletteLayout, y: &mu let track_x = minus_x + btn_size + SPACING_STD; let track_w = plus_x - track_x - SPACING_STD; let marker_track_y = marker_slider_row_y + (btn_size - track_h) / 2.0; - let min_opacity = opacity_spec.min; - let max_opacity = opacity_spec.max; - let t = ((snapshot.marker_opacity - min_opacity) / (max_opacity - min_opacity)).clamp(0.0, 1.0); - let knob_x = track_x + t * (track_w - knob_r * 2.0) + knob_r; + let knob_x = opacity_spec.knob_center_x(track_x, track_w, knob_r, snapshot.marker_opacity); set_color(ctx, COLOR_TRACK_BACKGROUND); draw_round_rect(ctx, track_x, marker_track_y, track_w, track_h, 4.0); @@ -131,8 +128,8 @@ pub(super) fn draw_marker_opacity_section(layout: &mut SidePaletteLayout, y: &mu rect: (track_x, marker_track_y - 6.0, track_w, track_h + 12.0), event: ToolbarEvent::SetMarkerOpacity(snapshot.marker_opacity), kind: HitKind::DragSetMarkerOpacity { - min: min_opacity, - max: max_opacity, + min: opacity_spec.min, + max: opacity_spec.max, }, tooltip: None, }); diff --git a/src/backend/wayland/toolbar/render/side_palette/step/custom_rows.rs b/src/backend/wayland/toolbar/render/side_palette/step/custom_rows.rs index 92232a7a..7b740fe8 100644 --- a/src/backend/wayland/toolbar/render/side_palette/step/custom_rows.rs +++ b/src/backend/wayland/toolbar/render/side_palette/step/custom_rows.rs @@ -1,7 +1,8 @@ -use crate::backend::wayland::toolbar::events::{HitKind, delay_secs_from_t, delay_t_from_ms}; +use crate::backend::wayland::toolbar::events::HitKind; use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; use crate::toolbar_icons; +use crate::ui::toolbar::model::{ToolbarSliderSpec, delay_secs_from_t, delay_t_from_ms}; use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; use crate::ui_text::UiTextStyle; @@ -199,7 +200,12 @@ impl<'a> CustomRowContext<'a> { draw_round_rect(self.ctx, self.x, slider_y, slider_w, slider_h, 3.0); let _ = self.ctx.fill(); let t = delay_t_from_ms(delay_ms); - let knob_x = self.x + t * (slider_w - slider_r * 2.0) + slider_r; + let knob_x = ToolbarSliderSpec::DELAY_SECONDS.knob_center_x( + self.x, + slider_w, + slider_r, + delay_ms as f64 / 1000.0, + ); self.ctx.set_source_rgba(0.25, 0.5, 0.95, 0.9); self.ctx.arc( knob_x, diff --git a/src/backend/wayland/toolbar/render/side_palette/step/delay_sliders.rs b/src/backend/wayland/toolbar/render/side_palette/step/delay_sliders.rs index a529760d..e28c837d 100644 --- a/src/backend/wayland/toolbar/render/side_palette/step/delay_sliders.rs +++ b/src/backend/wayland/toolbar/render/side_palette/step/delay_sliders.rs @@ -1,6 +1,7 @@ -use crate::backend::wayland::toolbar::events::{HitKind, delay_secs_from_t, delay_t_from_ms}; +use crate::backend::wayland::toolbar::events::HitKind; use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; +use crate::ui::toolbar::model::{ToolbarSliderSpec, delay_secs_from_t, delay_t_from_ms}; use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; use crate::ui_text::{UiTextStyle, draw_text_baseline}; @@ -43,7 +44,12 @@ pub(super) fn draw_delay_sliders( draw_round_rect(ctx, x, undo_slider_y, sliders_w, slider_h, 3.0); let _ = ctx.fill(); let undo_t = delay_t_from_ms(snapshot.undo_all_delay_ms); - let undo_knob_x = x + undo_t * (sliders_w - slider_knob_r * 2.0) + slider_knob_r; + let undo_knob_x = ToolbarSliderSpec::DELAY_SECONDS.knob_center_x( + x, + sliders_w, + slider_knob_r, + snapshot.undo_all_delay_ms as f64 / 1000.0, + ); ctx.set_source_rgba(0.25, 0.5, 0.95, 0.9); ctx.arc( undo_knob_x, @@ -88,7 +94,12 @@ pub(super) fn draw_delay_sliders( draw_round_rect(ctx, x, redo_slider_y, sliders_w, slider_h, 3.0); let _ = ctx.fill(); let redo_t = delay_t_from_ms(snapshot.redo_all_delay_ms); - let redo_knob_x = x + redo_t * (sliders_w - slider_knob_r * 2.0) + slider_knob_r; + let redo_knob_x = ToolbarSliderSpec::DELAY_SECONDS.knob_center_x( + x, + sliders_w, + slider_knob_r, + snapshot.redo_all_delay_ms as f64 / 1000.0, + ); ctx.set_source_rgba(0.25, 0.5, 0.95, 0.9); ctx.arc( redo_knob_x, diff --git a/src/backend/wayland/toolbar/render/side_palette/text.rs b/src/backend/wayland/toolbar/render/side_palette/text.rs index 0598b0e7..80df3233 100644 --- a/src/backend/wayland/toolbar/render/side_palette/text.rs +++ b/src/backend/wayland/toolbar/render/side_palette/text.rs @@ -116,8 +116,8 @@ pub(super) fn draw_text_controls_section(layout: &mut SidePaletteLayout, y: &mut let fs_track_x = fs_minus_x + btn_size + SPACING_STD; let fs_track_w = fs_plus_x - fs_track_x - SPACING_STD; let fs_track_y = fs_slider_row_y + (btn_size - track_h) / 2.0; - let fs_t = ((snapshot.font_size - fs_min) / (fs_max - fs_min)).clamp(0.0, 1.0); - let fs_knob_x = fs_track_x + fs_t * (fs_track_w - knob_r * 2.0) + knob_r; + let fs_knob_x = + font_size_spec.knob_center_x(fs_track_x, fs_track_w, knob_r, snapshot.font_size); set_color(ctx, COLOR_TRACK_BACKGROUND); draw_round_rect(ctx, fs_track_x, fs_track_y, fs_track_w, track_h, 4.0); diff --git a/src/backend/wayland/toolbar/render/side_palette/thickness.rs b/src/backend/wayland/toolbar/render/side_palette/thickness.rs index a0c4c479..296c578b 100644 --- a/src/backend/wayland/toolbar/render/side_palette/thickness.rs +++ b/src/backend/wayland/toolbar/render/side_palette/thickness.rs @@ -52,7 +52,6 @@ pub(super) fn draw_thickness_section(layout: &mut SidePaletteLayout, y: &mut f64 let track_h = ToolbarLayoutSpec::SIDE_TRACK_HEIGHT; let knob_r = ToolbarLayoutSpec::SIDE_TRACK_KNOB_RADIUS; let thickness_spec = ToolbarSliderSpec::THICKNESS; - let (min_thick, max_thick) = (thickness_spec.min, thickness_spec.max); let nudge_step = thickness_spec.step.unwrap_or(1.0); let minus_x = x; @@ -112,8 +111,7 @@ pub(super) fn draw_thickness_section(layout: &mut SidePaletteLayout, y: &mut f64 let track_x = minus_x + btn_size + SPACING_STD; let track_w = plus_x - track_x - SPACING_STD; let thickness_track_y = thickness_slider_row_y + (btn_size - track_h) / 2.0; - let t = ((snapshot.thickness - min_thick) / (max_thick - min_thick)).clamp(0.0, 1.0); - let knob_x = track_x + t * (track_w - knob_r * 2.0) + knob_r; + let knob_x = thickness_spec.knob_center_x(track_x, track_w, knob_r, snapshot.thickness); set_color(ctx, COLOR_TRACK_BACKGROUND); draw_round_rect(ctx, track_x, thickness_track_y, track_w, track_h, 4.0); @@ -132,8 +130,8 @@ pub(super) fn draw_thickness_section(layout: &mut SidePaletteLayout, y: &mut f64 rect: (track_x, thickness_track_y - 6.0, track_w, track_h + 12.0), event: ToolbarEvent::SetThickness(snapshot.thickness), kind: HitKind::DragSetThickness { - min: min_thick, - max: max_thick, + min: thickness_spec.min, + max: thickness_spec.max, }, tooltip: None, }); diff --git a/src/ui/toolbar/model/activation.rs b/src/ui/toolbar/model/activation.rs index 80c7749f..51dbc891 100644 --- a/src/ui/toolbar/model/activation.rs +++ b/src/ui/toolbar/model/activation.rs @@ -86,6 +86,15 @@ impl ToolbarSlider { ToolbarSliderTarget::CustomRedoDelay => ToolbarEvent::SetCustomRedoDelay(value), } } + + pub(crate) fn event_for_pointer_x( + &self, + pointer_x: f64, + hit_x: f64, + hit_w: f64, + ) -> ToolbarEvent { + self.event_for_value(self.spec.value_from_pointer_x(pointer_x, hit_x, hit_w)) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -131,9 +140,154 @@ impl ToolbarSliderSpec { pub(crate) fn clamp(self, value: f64) -> f64 { value.clamp(self.min, self.max) } + + pub(crate) fn value_from_t(self, t: f64) -> f64 { + self.clamp(self.min + t.clamp(0.0, 1.0) * self.span()) + } + + pub(crate) fn t_from_value(self, value: f64) -> f64 { + let span = self.span(); + if span <= f64::EPSILON { + return 0.0; + } + ((self.clamp(value) - self.min) / span).clamp(0.0, 1.0) + } + + pub(crate) fn t_from_pointer_x(pointer_x: f64, hit_x: f64, hit_w: f64) -> f64 { + if !hit_w.is_finite() || hit_w <= f64::EPSILON { + return 0.0; + } + ((pointer_x - hit_x) / hit_w).clamp(0.0, 1.0) + } + + pub(crate) fn value_from_pointer_x(self, pointer_x: f64, hit_x: f64, hit_w: f64) -> f64 { + self.value_from_t(Self::t_from_pointer_x(pointer_x, hit_x, hit_w)) + } + + pub(crate) fn knob_center_x( + self, + track_x: f64, + track_w: f64, + knob_radius: f64, + value: f64, + ) -> f64 { + let t = self.t_from_value(value); + track_x + t * (track_w - knob_radius * 2.0) + knob_radius + } + + fn span(self) -> f64 { + self.max - self.min + } +} + +/// Convert normalized delay slider position [0, 1] to seconds. +pub(crate) fn delay_secs_from_t(t: f64) -> f64 { + ToolbarSliderSpec::DELAY_SECONDS.value_from_t(t) +} + +/// Convert a delay in milliseconds to normalized slider position [0, 1]. +pub(crate) fn delay_t_from_ms(delay_ms: u64) -> f64 { + ToolbarSliderSpec::DELAY_SECONDS.t_from_value(delay_ms as f64 / 1000.0) } #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct ToolbarColorPicker { pub(crate) color: Color, } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < 0.000_001, + "expected {expected}, got {actual}" + ); + } + + #[test] + fn slider_spec_maps_values_to_normalized_positions() { + let spec = ToolbarSliderSpec { + min: 10.0, + max: 20.0, + step: None, + }; + + assert_close(spec.t_from_value(10.0), 0.0); + assert_close(spec.t_from_value(20.0), 1.0); + assert_close(spec.t_from_value(15.0), 0.5); + assert_close(spec.t_from_value(5.0), 0.0); + assert_close(spec.t_from_value(25.0), 1.0); + } + + #[test] + fn slider_spec_maps_normalized_positions_to_values() { + let spec = ToolbarSliderSpec { + min: 10.0, + max: 20.0, + step: None, + }; + + assert_close(spec.value_from_t(0.0), 10.0); + assert_close(spec.value_from_t(1.0), 20.0); + assert_close(spec.value_from_t(0.5), 15.0); + assert_close(spec.value_from_t(-1.0), 10.0); + assert_close(spec.value_from_t(2.0), 20.0); + } + + #[test] + fn pointer_mapping_uses_hit_rect_not_visual_knob_travel() { + let spec = ToolbarSliderSpec { + min: 10.0, + max: 20.0, + step: None, + }; + + assert_close(spec.value_from_pointer_x(100.0, 100.0, 200.0), 10.0); + assert_close(spec.value_from_pointer_x(200.0, 100.0, 200.0), 15.0); + assert_close(spec.value_from_pointer_x(300.0, 100.0, 200.0), 20.0); + assert_close(spec.value_from_pointer_x(50.0, 100.0, 200.0), 10.0); + assert_close(spec.value_from_pointer_x(350.0, 100.0, 200.0), 20.0); + } + + #[test] + fn visual_knob_mapping_uses_inset_travel_range() { + let spec = ToolbarSliderSpec { + min: 10.0, + max: 20.0, + step: None, + }; + + assert_close(spec.knob_center_x(100.0, 200.0, 8.0, 10.0), 108.0); + assert_close(spec.knob_center_x(100.0, 200.0, 8.0, 20.0), 292.0); + assert_close(spec.knob_center_x(100.0, 200.0, 8.0, 15.0), 200.0); + } + + #[test] + fn delay_helpers_use_delay_slider_spec() { + assert_close(delay_secs_from_t(0.0), ToolbarSliderSpec::DELAY_SECONDS.min); + assert_close(delay_secs_from_t(1.0), ToolbarSliderSpec::DELAY_SECONDS.max); + + let t = delay_t_from_ms(2525); + assert_close(delay_secs_from_t(t), 2.525); + } + + #[test] + fn slider_emits_event_from_pointer_position() { + let slider = ToolbarSlider { + target: ToolbarSliderTarget::Thickness, + spec: ToolbarSliderSpec { + min: 10.0, + max: 20.0, + step: None, + }, + value: 10.0, + }; + + match slider.event_for_pointer_x(200.0, 100.0, 200.0) { + ToolbarEvent::SetThickness(value) => assert_close(value, 15.0), + other => panic!("unexpected event: {other:?}"), + } + } +} diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 804617f9..a00fc9af 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -13,7 +13,7 @@ pub(crate) use actions::{ #[allow(unused_imports)] pub(crate) use activation::{ ToolbarActivation, ToolbarColorPicker, ToolbarControlId, ToolbarDragTarget, ToolbarSlider, - ToolbarSliderSpec, ToolbarSliderTarget, + ToolbarSliderSpec, ToolbarSliderTarget, delay_secs_from_t, delay_t_from_ms, }; #[allow(unused_imports)] pub(crate) use control::{