1use teksilo_canvas::{Point, Rect, Size, SizeProposal};
61use teksilo_core::accessibility::AccessNodeBuilder;
62use teksilo_core::binding::BindingLevel;
63use teksilo_core::build_context::BuildContext;
64use teksilo_core::signal::Signal;
65use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
66use teksilo_core::widget_id::WidgetId;
67
68use teksilo_widgets::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
69pub use teksilo_widgets::{ScrollBarMode, ScrollBarPolicy};
70
71use crate::scene::PanAxes;
72use crate::scene_model::SceneModel;
73use crate::view::SceneView;
74
75fn effective_extent(model: &SceneModel, override_bounds: Option<Rect>) -> Option<Rect> {
80 let scene_bounds = model.current_pan_bounds();
81 let merged = match (scene_bounds, override_bounds) {
82 (None, None) => None,
83 (Some(r), None) | (None, Some(r)) => Some(r),
84 (Some(a), Some(b)) => Some(intersect_rect(a, b)),
85 };
86 merged.or_else(|| model.0.borrow().scene_rect_extent())
87}
88
89fn intersect_rect(a: Rect, b: Rect) -> Rect {
93 let x = a.x.max(b.x);
94 let y = a.y.max(b.y);
95 let right = a.right().min(b.right());
96 let bottom = a.bottom().min(b.bottom());
97 if right > x && bottom > y {
98 Rect::new(x, y, right - x, bottom - y)
99 } else {
100 a
101 }
102}
103
104#[derive(Clone, Copy)]
106struct AxisMetrics {
107 max_x: f32,
108 max_y: f32,
109 ratio_x: f32,
110 ratio_y: f32,
111 pos_x: f32,
112 pos_y: f32,
113}
114
115pub struct SceneScrollView {
128 scene_view: Option<Box<SceneView>>,
130 scene_view_id: Option<WidgetId>,
131
132 pan_x: Signal<f32>,
134 pan_y: Signal<f32>,
135 zoom: Signal<f32>,
136 model: SceneModel,
137 pan_bounds_override: Signal<Option<Rect>>,
138
139 scroll_pos_x: Signal<f32>,
143 scroll_pos_y: Signal<f32>,
144 max_scroll_x: Signal<f32>,
145 max_scroll_y: Signal<f32>,
146 viewport_ratio_x: Signal<f32>,
147 viewport_ratio_y: Signal<f32>,
148
149 child_ids: Vec<WidgetId>,
151
152 scroll_bar_mode: ScrollBarMode,
154 vertical_policy: ScrollBarPolicy,
155 horizontal_policy: ScrollBarPolicy,
156 scroll_bar_thickness: f32,
157}
158
159impl std::fmt::Debug for SceneScrollView {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.debug_struct("SceneScrollView")
162 .field("mode", &self.scroll_bar_mode)
163 .field("v_policy", &self.vertical_policy)
164 .field("h_policy", &self.horizontal_policy)
165 .field("max_scroll_x", &self.max_scroll_x.get())
166 .field("max_scroll_y", &self.max_scroll_y.get())
167 .finish()
168 }
169}
170
171impl SceneScrollView {
172 pub fn new(view: SceneView) -> Self {
175 let pan_x = view.pan_x_signal();
176 let pan_y = view.pan_y_signal();
177 let zoom = view.zoom_signal();
178 let model = view.model();
179 let pan_bounds_override = view.pan_bounds_override_signal();
180
181 Self {
182 scene_view: Some(Box::new(view)),
183 scene_view_id: None,
184 pan_x,
185 pan_y,
186 zoom,
187 model,
188 pan_bounds_override,
189 scroll_pos_x: Signal::new(0.0),
190 scroll_pos_y: Signal::new(0.0),
191 max_scroll_x: Signal::new(0.0),
192 max_scroll_y: Signal::new(0.0),
193 viewport_ratio_x: Signal::new(1.0),
194 viewport_ratio_y: Signal::new(1.0),
195 child_ids: Vec::new(),
196 scroll_bar_mode: ScrollBarMode::default(),
197 vertical_policy: ScrollBarPolicy::default(),
198 horizontal_policy: ScrollBarPolicy::default(),
199 scroll_bar_thickness: 12.0,
200 }
201 }
202
203 pub fn scroll_bar_mode(mut self, mode: ScrollBarMode) -> Self {
205 self.scroll_bar_mode = mode;
206 self
207 }
208
209 pub fn vertical_policy(mut self, policy: ScrollBarPolicy) -> Self {
211 self.vertical_policy = policy;
212 self
213 }
214
215 pub fn horizontal_policy(mut self, policy: ScrollBarPolicy) -> Self {
217 self.horizontal_policy = policy;
218 self
219 }
220
221 pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self {
223 self.scroll_bar_thickness = thickness.max(0.0);
224 self
225 }
226
227 pub fn scroll_pos_x_signal(&self) -> &Signal<f32> {
230 &self.scroll_pos_x
231 }
232
233 pub fn scroll_pos_y_signal(&self) -> &Signal<f32> {
235 &self.scroll_pos_y
236 }
237
238 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
241 &self.max_scroll_x
242 }
243
244 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
246 &self.max_scroll_y
247 }
248
249 pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
251 &self.viewport_ratio_x
252 }
253
254 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
256 &self.viewport_ratio_y
257 }
258
259 fn metrics(&self, viewport_w: f32, viewport_h: f32) -> AxisMetrics {
263 let zoom = self.zoom.get();
264 let extent = effective_extent(&self.model, self.pan_bounds_override.get());
265 match extent {
266 Some(e) if e.width > 0.0 && e.height > 0.0 && zoom > 0.0 => {
267 let content_w = e.width * zoom;
268 let content_h = e.height * zoom;
269 let max_x = (content_w - viewport_w).max(0.0);
270 let max_y = (content_h - viewport_h).max(0.0);
271 let ratio_x = (viewport_w / content_w).clamp(0.0, 1.0);
272 let ratio_y = (viewport_h / content_h).clamp(0.0, 1.0);
273 let pos_x = (-self.pan_x.get() - e.x * zoom).clamp(0.0, max_x);
274 let pos_y = (-self.pan_y.get() - e.y * zoom).clamp(0.0, max_y);
275 AxisMetrics {
276 max_x,
277 max_y,
278 ratio_x,
279 ratio_y,
280 pos_x,
281 pos_y,
282 }
283 }
284 _ => AxisMetrics {
285 max_x: 0.0,
286 max_y: 0.0,
287 ratio_x: 1.0,
288 ratio_y: 1.0,
289 pos_x: 0.0,
290 pos_y: 0.0,
291 },
292 }
293 }
294
295 fn register_pan_effect(&self, ctx: &mut BuildContext, is_x: bool) {
299 let scroll_pos = if is_x {
300 self.scroll_pos_x.clone()
301 } else {
302 self.scroll_pos_y.clone()
303 };
304 let pan = if is_x {
305 self.pan_x.clone()
306 } else {
307 self.pan_y.clone()
308 };
309 let zoom = self.zoom.clone();
310 let model = self.model.clone();
311 let pan_bounds_override = self.pan_bounds_override.clone();
312
313 ctx.effect(&scroll_pos, move |new_pos| {
314 let axes = model.current_pan_axes();
317 let allowed = if is_x {
318 matches!(axes, PanAxes::Horizontal | PanAxes::Both)
319 } else {
320 matches!(axes, PanAxes::Vertical | PanAxes::Both)
321 };
322 if !allowed {
323 return;
324 }
325 let Some(extent) = effective_extent(&model, pan_bounds_override.get()) else {
326 return;
327 };
328 let z = zoom.get();
329 if z <= 0.0 {
330 return;
331 }
332 let extent_origin = if is_x { extent.x } else { extent.y };
333 let target_pan = -extent_origin * z - *new_pos;
334 let implied = -pan.get() - extent_origin * z;
338 if (implied - *new_pos).abs() < 0.5 {
339 return;
340 }
341 pan.set(target_pan);
344 });
345 }
346}
347
348fn resolve_show(policy: ScrollBarPolicy, axis_allowed: bool, max: f32) -> bool {
351 axis_allowed
352 && match policy {
353 ScrollBarPolicy::AlwaysOn => true,
354 ScrollBarPolicy::AlwaysOff => false,
355 ScrollBarPolicy::AsNeeded => max > 0.0,
356 }
357}
358
359impl Widget for SceneScrollView {
360 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
361 if self.child_ids.is_empty() {
363 let view = self
364 .scene_view
365 .take()
366 .expect("SceneScrollView: SceneView already consumed");
367 let sv_id = ctx.add(*view);
368 self.scene_view_id = Some(sv_id);
369
370 let visual = match self.scroll_bar_mode {
371 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
372 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
373 ScrollBarMode::Thin => ScrollBarVisual::Thin,
374 };
375
376 let v_bar = ScrollBar::new(
377 ScrollBarOrientation::Vertical,
378 self.scroll_pos_y.clone(),
379 self.max_scroll_y.clone(),
380 self.viewport_ratio_y.clone(),
381 )
382 .thickness(self.scroll_bar_thickness)
383 .visual(visual);
384 let v_id = ctx.add(v_bar);
385
386 let h_bar = ScrollBar::new(
387 ScrollBarOrientation::Horizontal,
388 self.scroll_pos_x.clone(),
389 self.max_scroll_x.clone(),
390 self.viewport_ratio_x.clone(),
391 )
392 .thickness(self.scroll_bar_thickness)
393 .visual(visual);
394 let h_id = ctx.add(h_bar);
395
396 self.child_ids = vec![sv_id, v_id, h_id];
397
398 let self_id = ctx.self_id();
402 let registry = ctx.binding_registry();
403 self.pan_x
404 .bind_to(self_id, registry, BindingLevel::Relayout);
405 self.pan_y
406 .bind_to(self_id, registry, BindingLevel::Relayout);
407 self.zoom.bind_to(self_id, registry, BindingLevel::Relayout);
408 self.model
409 .pan_bounds_signal()
410 .bind_to(self_id, registry, BindingLevel::Relayout);
411 self.model
417 .pan_axes_signal()
418 .bind_to(self_id, registry, BindingLevel::Relayout);
419 self.pan_bounds_override
420 .bind_to(self_id, registry, BindingLevel::Relayout);
421 }
422
423 self.register_pan_effect(ctx, true);
426 self.register_pan_effect(ctx, false);
427
428 self.child_ids.clone()
429 }
430
431 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
432 self.scene_view_id
434 .and_then(|id| ctx.child_size(id, proposal))
435 .map(LayoutResponse::from)
436 .unwrap_or_else(|| proposal.resolve(800.0, 600.0).into())
437 }
438
439 fn place_children(
440 &self,
441 bounds: Rect,
442 _proposal: SizeProposal,
443 children: &mut [WidgetPlacement],
444 ctx: &LayoutContext,
445 ) {
446 if children.len() < 3 {
447 return;
448 }
449
450 let axes = self.model.current_pan_axes();
451 let h_allowed = matches!(axes, PanAxes::Horizontal | PanAxes::Both);
452 let v_allowed = matches!(axes, PanAxes::Vertical | PanAxes::Both);
453 let sb = self.scroll_bar_thickness;
454 let permanent = self.scroll_bar_mode == ScrollBarMode::Permanent;
455
456 let m1 = self.metrics(bounds.width, bounds.height);
459 let show_v1 = resolve_show(self.vertical_policy, v_allowed, m1.max_y);
460 let show_h1 = resolve_show(self.horizontal_policy, h_allowed, m1.max_x);
461 let v_reserved = if permanent && show_v1 { sb } else { 0.0 };
462 let h_reserved = if permanent && show_h1 { sb } else { 0.0 };
463
464 let viewport_w = (bounds.width - v_reserved).max(0.0);
466 let viewport_h = (bounds.height - h_reserved).max(0.0);
467 let m = self.metrics(viewport_w, viewport_h);
468 let show_v = resolve_show(self.vertical_policy, v_allowed, m.max_y);
469 let show_h = resolve_show(self.horizontal_policy, h_allowed, m.max_x);
470
471 self.max_scroll_x.set_if_changed(m.max_x);
474 self.max_scroll_y.set_if_changed(m.max_y);
475 self.viewport_ratio_x.set_if_changed(m.ratio_x);
476 self.viewport_ratio_y.set_if_changed(m.ratio_y);
477 self.scroll_pos_x.set_if_changed(m.pos_x);
478 self.scroll_pos_y.set_if_changed(m.pos_y);
479
480 let content_x = if ctx.is_rtl() {
486 bounds.x + v_reserved
487 } else {
488 bounds.x
489 };
490 children[0].origin = Point::new(content_x, bounds.y);
491 children[0].size = Size::new(viewport_w, viewport_h);
492
493 if show_v {
495 let sb_x = if ctx.is_rtl() {
496 bounds.x
497 } else {
498 bounds.right() - sb
499 };
500 let sb_h = if h_reserved > 0.0 || (!permanent && show_h) {
501 bounds.height - sb
502 } else {
503 bounds.height
504 };
505 children[1].origin = Point::new(sb_x, bounds.y);
506 children[1].size = Size::new(sb, sb_h);
507 } else {
508 children[1].origin = bounds.origin();
509 children[1].size = Size::ZERO;
510 }
511
512 if show_h {
514 let sb_y = bounds.bottom() - sb;
515 let sb_x = if ctx.is_rtl() && v_reserved > 0.0 {
516 bounds.x + sb
517 } else {
518 bounds.x
519 };
520 let sb_w = if v_reserved > 0.0 || (!permanent && show_v) {
521 bounds.width - sb
522 } else {
523 bounds.width
524 };
525 children[2].origin = Point::new(sb_x, sb_y);
526 children[2].size = Size::new(sb_w, sb);
527 } else {
528 children[2].origin = bounds.origin();
529 children[2].size = Size::ZERO;
530 }
531 }
532
533 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {
534 }
536
537 fn children(&self) -> Vec<WidgetId> {
538 self.child_ids.clone()
539 }
540
541 fn clips_children(&self) -> bool {
542 true
543 }
544
545 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
546 builder.set_role(teksilo_core::accesskit::Role::Group);
550 builder.inner_mut().set_clips_children();
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::Scene;
558 use teksilo_core::widget_tree::WidgetTree;
559
560 fn bounded(extent: Rect, axes: PanAxes, mode: ScrollBarMode) -> SceneScrollView {
562 let mut scene = Scene::new();
563 scene.set_pan_bounds(Some(extent));
564 scene.pan_axes(axes);
565 SceneView::new(scene)
566 .with_scroll_bars()
567 .scroll_bar_mode(mode)
568 }
569
570 #[test]
571 fn metrics_from_known_extent() {
572 let mut tree = WidgetTree::new();
573 let scrollable = bounded(
574 Rect::new(0.0, 0.0, 800.0, 600.0),
575 PanAxes::Both,
576 ScrollBarMode::Overlay,
577 );
578 let max_x = scrollable.max_scroll_x_signal().clone();
579 let max_y = scrollable.max_scroll_y_signal().clone();
580 let ratio_x = scrollable.viewport_ratio_x_signal().clone();
581 let pos_x = scrollable.scroll_pos_x_signal().clone();
582
583 let _id = tree.add(scrollable);
584 tree.layout(SizeProposal::exact(400.0, 300.0));
585
586 assert!((max_x.get() - 400.0).abs() < 0.5, "max_x = {}", max_x.get());
587 assert!((max_y.get() - 300.0).abs() < 0.5, "max_y = {}", max_y.get());
588 assert!(
589 (ratio_x.get() - 0.5).abs() < 0.01,
590 "ratio_x = {}",
591 ratio_x.get()
592 );
593 assert!(pos_x.get().abs() < 0.5, "pos_x = {}", pos_x.get());
594 }
595
596 #[test]
597 fn as_needed_hides_bar_when_content_fits() {
598 let mut tree = WidgetTree::new();
600 let scrollable = bounded(
601 Rect::new(0.0, 0.0, 200.0, 200.0),
602 PanAxes::Both,
603 ScrollBarMode::Overlay,
604 );
605 let id = tree.add(scrollable);
606 tree.layout(SizeProposal::exact(400.0, 300.0));
607
608 let children = tree.children(id);
609 assert_eq!(children.len(), 3);
610 let v_sb = tree.bounds(children[1]);
611 assert!(
612 v_sb.width.abs() < 0.01 && v_sb.height.abs() < 0.01,
613 "v_sb = {:?}",
614 v_sb
615 );
616 let h_sb = tree.bounds(children[2]);
617 assert!(
618 h_sb.width.abs() < 0.01 && h_sb.height.abs() < 0.01,
619 "h_sb = {:?}",
620 h_sb
621 );
622 }
623
624 #[test]
625 fn permanent_mode_reserves_gutter() {
626 let mut tree = WidgetTree::new();
627 let scrollable = bounded(
628 Rect::new(0.0, 0.0, 800.0, 600.0),
629 PanAxes::Both,
630 ScrollBarMode::Permanent,
631 )
632 .scroll_bar_thickness(12.0);
633 let id = tree.add(scrollable);
634 tree.layout(SizeProposal::exact(400.0, 300.0));
635
636 let children = tree.children(id);
637 let scene_view = tree.bounds(children[0]);
638 assert!(
639 (scene_view.width - 388.0).abs() < 0.5,
640 "scene view width = {}",
641 scene_view.width
642 );
643 assert!(
644 (scene_view.height - 288.0).abs() < 0.5,
645 "scene view height = {}",
646 scene_view.height
647 );
648 }
649
650 #[test]
651 fn pan_axes_horizontal_hides_vertical_bar() {
652 let mut tree = WidgetTree::new();
654 let scrollable = bounded(
655 Rect::new(0.0, 0.0, 800.0, 600.0),
656 PanAxes::Horizontal,
657 ScrollBarMode::Overlay,
658 );
659 let id = tree.add(scrollable);
660 tree.layout(SizeProposal::exact(400.0, 300.0));
661
662 let children = tree.children(id);
663 let v_sb = tree.bounds(children[1]);
664 assert!(
665 v_sb.width.abs() < 0.01 && v_sb.height.abs() < 0.01,
666 "vertical bar should be hidden when axis locked, got {:?}",
667 v_sb
668 );
669 let h_sb = tree.bounds(children[2]);
671 assert!(
672 h_sb.width > 0.0,
673 "horizontal bar should be visible, got {:?}",
674 h_sb
675 );
676 }
677
678 #[test]
679 fn zoom_changes_max_scroll() {
680 let mut scene = Scene::new();
681 scene.set_pan_bounds(Some(Rect::new(0.0, 0.0, 800.0, 600.0)));
682 let view = SceneView::new(scene);
683 let zoom = view.zoom_signal();
684 let scrollable = view.with_scroll_bars();
685 let max_x = scrollable.max_scroll_x_signal().clone();
686
687 let mut tree = WidgetTree::new();
688 let _id = tree.add(scrollable);
689 tree.layout(SizeProposal::exact(400.0, 300.0));
690 assert!(
691 (max_x.get() - 400.0).abs() < 0.5,
692 "zoom=1 max_x = {}",
693 max_x.get()
694 );
695
696 zoom.set(2.0);
697 tree.layout(SizeProposal::exact(400.0, 300.0));
698 assert!(
700 (max_x.get() - 1200.0).abs() < 0.5,
701 "zoom=2 max_x = {}",
702 max_x.get()
703 );
704 }
705
706 #[test]
710 fn scroll_pos_drives_pan_without_feedback_loop() {
711 let mut scene = Scene::new();
712 scene.set_pan_bounds(Some(Rect::new(0.0, 0.0, 800.0, 600.0)));
713 let view = SceneView::new(scene);
714 let pan_x = view.pan_x_signal();
715 let scrollable = view.with_scroll_bars();
716 let scroll_pos_x = scrollable.scroll_pos_x_signal().clone();
717
718 let mut tree = WidgetTree::new();
719 let _id = tree.add(scrollable);
720 tree.layout(SizeProposal::exact(400.0, 300.0));
721 assert!(pan_x.get().abs() < 0.5, "initial pan_x = {}", pan_x.get());
722
723 scroll_pos_x.set(200.0);
726 assert!(
727 (pan_x.get() - (-200.0)).abs() < 0.5,
728 "pan_x should follow scroll, got {}",
729 pan_x.get()
730 );
731
732 tree.layout(SizeProposal::exact(400.0, 300.0));
735 assert!(
736 (scroll_pos_x.get() - 200.0).abs() < 0.5,
737 "scroll_pos_x should settle at 200, got {}",
738 scroll_pos_x.get()
739 );
740 assert!(
741 (pan_x.get() - (-200.0)).abs() < 0.5,
742 "pan_x should stay at -200, got {}",
743 pan_x.get()
744 );
745 }
746
747 #[test]
750 fn locked_axis_ignores_scroll_writes() {
751 let mut scene = Scene::new();
752 scene.set_pan_bounds(Some(Rect::new(0.0, 0.0, 800.0, 600.0)));
753 scene.pan_axes(PanAxes::Horizontal); let view = SceneView::new(scene);
755 let pan_y = view.pan_y_signal();
756 let scrollable = view.with_scroll_bars();
757 let scroll_pos_y = scrollable.scroll_pos_y_signal().clone();
758
759 let mut tree = WidgetTree::new();
760 let _id = tree.add(scrollable);
761 tree.layout(SizeProposal::exact(400.0, 300.0));
762
763 scroll_pos_y.set(150.0);
764 assert!(
765 pan_y.get().abs() < 0.5,
766 "locked vertical axis must not pan, got {}",
767 pan_y.get()
768 );
769 }
770}