1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeSet, HashMap};
3use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, CursorIcon, Insets, Key, Point, Rect, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::{Binding, State};
9use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
10
11use super::time::{TimeAxis, TimePoint, TimeScale, assign_lanes};
12use crate::context::{BuildCx, Drag, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::display::Tone;
14use crate::overlay::{Entry, MenuList};
15use crate::{Button, ButtonSize, ButtonStyle, IconButton, IconName, Labels, MenuItem, Text, View, util};
16
17const LANE_HEIGHT: f32 = 30.0;
18const TALL_LANE_HEIGHT: f32 = 48.0;
20const CARD_BAR_GAP: f32 = 2.0;
21const AUTO_SCROLL_EDGE: f32 = 32.0;
23const REVEAL_MARGIN: f32 = 16.0;
24const AUTO_SCROLL_STEP: f32 = 12.0;
25const AUTO_SCROLL_EVERY: f64 = 1.0 / 60.0;
26const ROW_HOVER_TINT: f32 = 0.03;
28const PINCH_STEP: f32 = 0.2;
30const AVATAR_SIZE: f32 = 28.0;
31const AVATAR_GAP: f32 = 10.0;
32const TINT_REST: f32 = 0.13;
33const TINT_HOVERED: f32 = 0.22;
34const TINT_SELECTED: f32 = 0.3;
35const TINT_DONE: f32 = 0.06;
36const TINT_TEXT_TOWARD_INK: f32 = 0.4;
37const STRIPE_WIDTH: f32 = 3.0;
38const STRIPE_INSET: f32 = 3.0;
39const TODAY_TINT: f32 = 0.05;
41const NAME_TOP: f32 = 12.0;
43const ROW_PADDING: f32 = 4.0;
44const GROUP_HEIGHT: f32 = 30.0;
45const HEADER_ROW_HEIGHT: f32 = 26.0;
46const MIN_BAR_WIDTH: f32 = 6.0;
47const HANDLE_WIDTH: f32 = 6.0;
48const OVERSCAN: f32 = 200.0;
50
51#[derive(Clone, Debug, PartialEq, Eq)]
53#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
54pub struct Resource {
55 pub id: u64,
57 pub name: String,
59 pub subtitle: Option<String>,
61 pub group: Option<String>,
63}
64
65#[cfg_attr(feature = "live", ravenrs_live::expose)]
66impl Resource {
67 pub fn new(id: u64, name: impl Into<String>) -> Self {
69 Self { id, name: name.into(), subtitle: None, group: None }
70 }
71
72 pub fn subtitle(mut self, text: impl Into<String>) -> Self {
74 self.subtitle = Some(text.into());
75 self
76 }
77
78 pub fn group(mut self, name: impl Into<String>) -> Self {
80 self.group = Some(name.into());
81 self
82 }
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
87#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
88pub struct SchedulerEvent {
89 pub id: u64,
91 pub resource: u64,
93 pub start: TimePoint,
95 pub end: TimePoint,
97 pub title: String,
99 pub tone: Tone,
101 pub done: bool,
103 pub locked: bool,
105 pub resizable: bool,
107}
108
109#[cfg_attr(feature = "live", ravenrs_live::expose)]
110impl SchedulerEvent {
111 pub fn new(id: u64, resource: u64, start: TimePoint, end: TimePoint, title: impl Into<String>) -> Self {
113 Self {
114 id,
115 resource,
116 start,
117 end,
118 title: title.into(),
119 tone: Tone::Accent,
120 done: false,
121 locked: false,
122 resizable: true,
123 }
124 }
125
126 pub fn tone(mut self, tone: Tone) -> Self {
128 self.tone = tone;
129 self
130 }
131
132 pub fn done(mut self, done: bool) -> Self {
134 self.done = done;
135 self
136 }
137
138 pub fn locked(mut self, locked: bool) -> Self {
140 self.locked = locked;
141 self
142 }
143
144 pub fn resizable(mut self, resizable: bool) -> Self {
146 self.resizable = resizable;
147 self
148 }
149}
150
151#[derive(Clone, Debug, PartialEq, Eq)]
153#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
154pub struct SchedulerRange {
155 pub start: TimePoint,
157 pub end: TimePoint,
159 pub tone: Tone,
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
166pub struct SchedulerChange {
167 pub id: Option<u64>,
169 pub resource: u64,
171 pub start: TimePoint,
173 pub end: TimePoint,
175}
176
177type CanDrop = Rc<dyn Fn(&SchedulerChange) -> bool>;
178type OnChange = Rc<dyn Fn(SchedulerChange)>;
179
180#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
207pub struct Scheduler {
208 axis: Binding<TimeAxis>,
209 resources: Vec<Resource>,
210 events: Vec<SchedulerEvent>,
211 ranges: Vec<SchedulerRange>,
212 now: Option<TimePoint>,
213 selected: Option<Binding<Option<u64>>>,
214 resource_width: f32,
215 read_only: bool,
216 toolbar: bool,
217 fill_rows: bool,
218 card: bool,
219 avatars: bool,
220 event_detail: Option<EventDetail>,
221 event_menu: Option<EventMenu>,
222 lane_menu: Option<LaneMenu>,
223 on_change: Option<OnChange>,
224 on_create: Option<OnChange>,
225 on_open: Option<Rc<dyn Fn(u64)>>,
226 on_delete: Option<Rc<dyn Fn(u64)>>,
227 can_drop: Option<CanDrop>,
228}
229
230impl fmt::Debug for Scheduler {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 f.debug_struct("Scheduler")
233 .field("resources", &self.resources.len())
234 .field("events", &self.events.len())
235 .finish_non_exhaustive()
236 }
237}
238
239#[cfg_attr(feature = "live", ravenrs_live::expose)]
240impl Scheduler {
241 pub fn new(axis: Binding<TimeAxis>) -> Self {
243 Self {
244 axis,
245 resources: Vec::new(),
246 events: Vec::new(),
247 ranges: Vec::new(),
248 now: None,
249 selected: None,
250 resource_width: 200.0,
251 read_only: false,
252 toolbar: true,
253 fill_rows: false,
254 card: false,
255 avatars: false,
256 event_detail: None,
257 event_menu: None,
258 lane_menu: None,
259 on_change: None,
260 on_create: None,
261 on_open: None,
262 on_delete: None,
263 can_drop: None,
264 }
265 }
266
267 pub fn resources(mut self, resources: impl IntoIterator<Item = Resource>) -> Self {
269 self.resources = resources.into_iter().collect();
270 self
271 }
272
273 pub fn events(mut self, events: impl IntoIterator<Item = SchedulerEvent>) -> Self {
275 self.events = events.into_iter().collect();
276 self
277 }
278
279 pub fn ranges(mut self, ranges: impl IntoIterator<Item = SchedulerRange>) -> Self {
281 self.ranges = ranges.into_iter().collect();
282 self
283 }
284
285 pub fn now(mut self, now: TimePoint) -> Self {
287 self.now = Some(now);
288 self
289 }
290
291 pub fn selection(mut self, selected: Binding<Option<u64>>) -> Self {
293 self.selected = Some(selected);
294 self
295 }
296
297 pub fn resource_width(mut self, width: f32) -> Self {
299 self.resource_width = width.max(80.0);
300 self
301 }
302
303 pub fn fill_rows(mut self, fill: bool) -> Self {
306 self.fill_rows = fill;
307 self
308 }
309
310 pub fn card(mut self, card: bool) -> Self {
315 self.card = card;
316 self
317 }
318
319 pub fn avatars(mut self, avatars: bool) -> Self {
321 self.avatars = avatars;
322 self
323 }
324
325 pub fn event_detail(mut self, detail: impl Fn(u64) -> Option<String> + 'static) -> Self {
327 self.event_detail = Some(Rc::new(detail));
328 self
329 }
330
331 pub fn event_menu(mut self, menu: impl Fn(u64) -> Vec<MenuItem> + 'static) -> Self {
333 self.event_menu = Some(Rc::new(menu));
334 self
335 }
336
337 pub fn lane_menu(mut self, menu: impl Fn(SchedulerChange) -> Vec<MenuItem> + 'static) -> Self {
339 self.lane_menu = Some(Rc::new(menu));
340 self
341 }
342
343 pub fn read_only(mut self, read_only: bool) -> Self {
345 self.read_only = read_only;
346 self
347 }
348
349 pub fn toolbar(mut self, show: bool) -> Self {
351 self.toolbar = show;
352 self
353 }
354
355 pub fn on_change(mut self, handler: impl Fn(SchedulerChange) + 'static) -> Self {
357 self.on_change = Some(Rc::new(handler));
358 self
359 }
360
361 pub fn on_create(mut self, handler: impl Fn(SchedulerChange) + 'static) -> Self {
363 self.on_create = Some(Rc::new(handler));
364 self
365 }
366
367 pub fn on_open(mut self, handler: impl Fn(u64) + 'static) -> Self {
369 self.on_open = Some(Rc::new(handler));
370 self
371 }
372
373 pub fn on_delete(mut self, handler: impl Fn(u64) + 'static) -> Self {
375 self.on_delete = Some(Rc::new(handler));
376 self
377 }
378
379 pub fn can_drop(mut self, rule: impl Fn(&SchedulerChange) -> bool + 'static) -> Self {
381 self.can_drop = Some(Rc::new(rule));
382 self
383 }
384}
385
386#[derive(Clone)]
388enum Row {
389 Group { name: String, count: usize, collapsed: bool, y: f32 },
390 Resource { index: usize, y: f32, height: f32 },
391}
392
393#[cfg_attr(feature = "live", ravenrs_live::expose)]
394impl Row {
395 fn y(&self) -> f32 {
396 match self {
397 Row::Group { y, .. } | Row::Resource { y, .. } => *y,
398 }
399 }
400
401 fn height(&self) -> f32 {
402 match self {
403 Row::Group { .. } => GROUP_HEIGHT,
404 Row::Resource { height, .. } => *height,
405 }
406 }
407}
408
409type FollowOn = Rc<RefCell<Option<Rc<dyn Fn(Point)>>>>;
411type EventDetail = Rc<dyn Fn(u64) -> Option<String>>;
412type EventMenu = Rc<dyn Fn(u64) -> Vec<MenuItem>>;
413type LaneMenu = Rc<dyn Fn(SchedulerChange) -> Vec<MenuItem>>;
414
415fn open_menu(cx: &mut BuildCx<'_>, anchor: NodeId, at: Point, items: Vec<MenuItem>, close: Rc<dyn Fn()>) {
417 let entries: Vec<Entry> = items.into_iter().map(Entry::Item).collect();
418 cx.overlay(
419 anchor,
420 Placement::At(at),
421 &MenuList { entries: &entries, close: close.clone(), on_side: None },
422 Some(close),
423 );
424}
425
426#[derive(Clone, Copy, Debug, PartialEq)]
428enum DragKind {
429 Move {
431 grab: i64,
432 },
433 ResizeStart,
434 ResizeEnd,
435}
436
437#[derive(Clone, Copy, Debug, PartialEq)]
439struct Preview {
440 change: SchedulerChange,
441 allowed: bool,
442}
443
444#[derive(Clone)]
446struct Editing {
447 axis: TimeAxis,
448 rows: Rc<Vec<(u64, f32, f32)>>,
449 preview: State<Option<Preview>>,
450 on_change: Option<OnChange>,
451 on_create: Option<OnChange>,
452 can_drop: Option<CanDrop>,
453 scroll: Rc<Cell<f32>>,
455 pointer: Rc<Cell<Option<(f32, f32)>>>,
457 follow_on: FollowOn,
459 view_width: Rc<Cell<f32>>,
461 reveal: Rc<Cell<Option<(f32, f32)>>>,
463}
464
465#[cfg_attr(feature = "live", ravenrs_live::expose)]
466impl Editing {
467 fn resource_at(&self, y: f32) -> Option<u64> {
468 let found = self.rows.iter().find(|(_, top, height)| y >= *top && y < top + height);
469 found.or_else(|| if y < 0.0 { self.rows.first() } else { self.rows.last() }).map(|r| r.0)
470 }
471
472 fn allowed(&self, change: &SchedulerChange) -> bool {
473 self.can_drop.as_ref().is_none_or(|rule| rule(change))
474 }
475
476 fn propose(&self, change: SchedulerChange) {
477 self.preview.set(Some(Preview { change, allowed: self.allowed(&change) }));
478 }
479
480 fn commit(&self, original: Option<SchedulerChange>) {
482 let Some(preview) = *self.preview.peek() else { return };
483 self.preview.set(None);
484 if !preview.allowed || original == Some(preview.change) {
485 return;
486 }
487 let handler = if preview.change.id.is_some() { &self.on_change } else { &self.on_create };
488 if let Some(handler) = handler {
489 handler(preview.change);
490 }
491 }
492
493 fn placed(&self, event: &SchedulerEvent, kind: DragKind, at: Point) -> SchedulerChange {
495 let time = self.axis.snap(self.axis.time_at(at.x));
496 let original = SchedulerChange {
497 id: Some(event.id),
498 resource: event.resource,
499 start: event.start,
500 end: event.end,
501 };
502 let step = self.axis.snap_minutes();
503 match kind {
504 DragKind::Move { grab } => {
505 let start = self.axis.snap(self.axis.time_at(at.x).plus_minutes(-grab));
506 let length = event.end.minutes() - event.start.minutes();
507 let resource = self.resource_at(at.y).unwrap_or(event.resource);
508 SchedulerChange { start, end: start.plus_minutes(length), resource, ..original }
509 }
510 DragKind::ResizeStart => {
511 SchedulerChange { start: time.min(event.end.plus_minutes(-step)), ..original }
512 }
513 DragKind::ResizeEnd => {
514 SchedulerChange { end: time.max(event.start.plus_minutes(step)), ..original }
515 }
516 }
517 }
518
519 fn track(&self, at: Point, follow_on: Rc<dyn Fn(Point)>) {
521 self.pointer.set(Some((at.x - self.scroll.get(), at.y)));
522 *self.follow_on.borrow_mut() = Some(follow_on);
523 }
524
525 fn stop_tracking(&self) {
526 self.pointer.set(None);
527 *self.follow_on.borrow_mut() = None;
528 }
529
530 fn follow(&self, event: &SchedulerEvent, kind: DragKind, origin: &Cell<Point>, drag: Drag, bar: Point) {
532 if drag.phase == DragPhase::Start {
534 origin
535 .set(Point::new(drag.container.x() - bar.x + self.scroll.get(), drag.container.y() - bar.y));
536 }
537 let at = Point::new(
538 drag.position.x - origin.get().x + self.scroll.get(),
539 drag.position.y - origin.get().y,
540 );
541 let change = self.placed(event, kind, at);
542 match drag.phase {
543 DragPhase::End => {
544 self.stop_tracking();
545 self.propose(change);
546 self.commit(Some(SchedulerChange {
547 id: Some(event.id),
548 resource: event.resource,
549 start: event.start,
550 end: event.end,
551 }));
552 }
553 _ => {
554 let (editor, event) = (self.clone(), event.clone());
555 self.track(at, Rc::new(move |at| editor.propose(editor.placed(&event, kind, at))));
556 self.propose(change);
557 }
558 }
559 }
560}
561
562struct EndHandle {
563 event: SchedulerEvent,
564 kind: DragKind,
565 editing: Editing,
566 bar: Point,
567 width: f32,
568 height: f32,
569}
570
571impl View for EndHandle {
572 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
573 let at_start = self.kind == DragKind::ResizeStart;
574 let x = if at_start { 0.0 } else { (self.width - HANDLE_WIDTH).max(0.0) };
575 let node = cx.container(
576 LayoutStyle {
577 absolute: Some((x, 0.0)),
578 width: Some(HANDLE_WIDTH),
579 height: Some(self.height),
580 ..LayoutStyle::leaf()
581 },
582 &[],
583 Paint::None,
584 );
585 let origin = cx.use_state(|| Rc::new(Cell::new(Point::ZERO))).get();
586 let (event, kind, editing) = (self.event.clone(), self.kind, self.editing.clone());
587 let bar = self.bar;
588 cx.on_drag(
589 node,
590 Rc::new(move |drag: Drag| editing.follow(&event, kind, &origin, drag, bar)),
592 );
593 cx.cursor(node, CursorIcon::ColResize);
594 node
595 }
596}
597
598struct EventTip<'a> {
600 title: &'a str,
601 when: String,
602 detail: Option<&'a str>,
603}
604
605impl View for EventTip<'_> {
606 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
607 let theme = *cx.theme();
608 let ink = theme.color(Semantic::Canvas);
610 let title = util::label(cx, self.title, TextStyle::Callout, Some(Weight::SEMIBOLD), ink);
611 let line = match self.detail {
612 Some(detail) => format!("{} · {detail}", self.when),
613 None => self.when.clone(),
614 };
615 let line = util::label(cx, &line, TextStyle::Callout, None, ink.with_alpha(0.8));
616 let node = cx.container(
617 LayoutStyle {
618 padding: Insets::symmetric(6.0, 10.0),
619 spacing: 2.0,
620 ..LayoutStyle::stack(Axis::Vertical)
621 },
622 &[title, line],
623 Paint::Quad(QuadPaint { corner_radius: 7.0, ..QuadPaint::new(theme.color(Semantic::Ink)) }),
624 );
625 cx.semantics(node, Semantics::hidden());
627 crate::motion::popup(cx, node);
628 node
629 }
630}
631
632struct RowBand {
634 frame: Rect,
635 draw: Option<Rc<dyn Fn(Drag)>>,
636}
637
638impl View for RowBand {
639 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
640 let ink = cx.theme().color(Semantic::Ink);
641 let paint = if cx.is_hovered() {
642 Paint::Quad(QuadPaint::new(ink.with_alpha(ROW_HOVER_TINT)))
643 } else {
644 Paint::None
645 };
646 let node = cx.container(
647 LayoutStyle {
648 absolute: Some((self.frame.x(), self.frame.y())),
649 width: Some(self.frame.width()),
650 height: Some(self.frame.height()),
651 ..LayoutStyle::leaf()
652 },
653 &[],
654 paint,
655 );
656 cx.cursor(node, CursorIcon::Default);
659 if let Some(draw) = self.draw.clone() {
660 cx.on_drag(node, Rc::new(move |drag: Drag| draw(Drag { frame: drag.container, ..drag })));
661 }
662 node
663 }
664}
665
666struct Bar<'a> {
667 event: &'a SchedulerEvent,
668 detail: Option<String>,
670 at: (TimePoint, TimePoint),
672 menu: Option<EventMenu>,
673 tinted: Option<ravenrs_core::Color>,
675 frame: Rect,
677 title: String,
678 dragging: Option<bool>,
680 selected: bool,
681 tab_stop: bool,
683 editable: bool,
684 editing: Editing,
685 selection: Option<Binding<Option<u64>>>,
687 on_open: Option<Rc<dyn Fn(u64)>>,
688 on_delete: Option<Rc<dyn Fn(u64)>>,
689}
690
691#[cfg_attr(feature = "live", ravenrs_live::expose)]
692impl Bar<'_> {
693 fn color(&self, theme: &Theme) -> ravenrs_core::Color {
694 match self.dragging {
695 Some(false) => theme.color(Semantic::Danger),
696 _ if self.event.done => theme.color(Semantic::InkMuted),
697 _ => self.event.tone.color(theme),
698 }
699 }
700
701 fn key_handler(&self) -> crate::KeyHandler {
702 let event = self.event.clone();
703 let (editing, editable) = (self.editing.clone(), self.editable && !event.locked);
704 let (on_open, on_delete) = (self.on_open.clone(), self.on_delete.clone());
705 Rc::new(move |key, modifiers| {
706 let step = editing.axis.snap_minutes();
707 let original = SchedulerChange {
708 id: Some(event.id),
709 resource: event.resource,
710 start: event.start,
711 end: event.end,
712 };
713 let change = match key {
714 Key::Enter => {
715 if let Some(open) = &on_open {
716 open(event.id);
717 }
718 return on_open.is_some();
719 }
720 Key::Delete | Key::Backspace if editable => {
721 if let Some(delete) = &on_delete {
722 delete(event.id);
723 }
724 return on_delete.is_some();
725 }
726 Key::ArrowLeft | Key::ArrowRight if editable => {
727 let by = if *key == Key::ArrowLeft { -step } else { step };
728 if modifiers.shift && !event.resizable {
729 return true;
730 }
731 if modifiers.shift {
732 let end = event.end.plus_minutes(by);
733 SchedulerChange { end: end.max(event.start.plus_minutes(step)), ..original }
734 } else {
735 SchedulerChange {
736 start: event.start.plus_minutes(by),
737 end: event.end.plus_minutes(by),
738 ..original
739 }
740 }
741 }
742 Key::ArrowUp | Key::ArrowDown if editable => {
743 let here = editing.rows.iter().position(|r| r.0 == event.resource);
744 let there =
745 here.and_then(|i| if *key == Key::ArrowUp { i.checked_sub(1) } else { Some(i + 1) });
746 let Some(row) = there.and_then(|i| editing.rows.get(i)) else { return true };
747 SchedulerChange { resource: row.0, ..original }
748 }
749 _ => return false,
750 };
751 editing.propose(change);
752 editing.commit(Some(original));
753 true
754 })
755 }
756}
757
758impl View for Bar<'_> {
759 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
760 let theme = *cx.theme();
761 let e = self.event;
762 let color = self.color(&theme);
763 let hovered = cx.is_hovered() || cx.is_focus_visible();
764 let solid = !e.done && self.dragging.is_none();
765 let fill = match self.tinted {
766 Some(surface) => {
768 let strength = if self.selected {
769 TINT_SELECTED
770 } else if hovered && solid {
771 TINT_HOVERED
772 } else if e.done {
773 TINT_DONE
774 } else {
775 TINT_REST
776 };
777 surface.lerp(color, strength)
778 }
779 None if self.selected || hovered && solid => color,
780 None => color.with_alpha(if e.done { 0.16 } else { 0.8 }),
781 };
782 let ink = match self.tinted {
783 Some(_) => color.lerp(theme.color(Semantic::Ink), TINT_TEXT_TOWARD_INK),
784 None if e.done => theme.color(Semantic::Ink),
785 None => theme.color(Semantic::OnAccent),
786 };
787 let mut children = Vec::new();
788 if e.done {
789 children.push(util::icon(cx, IconName::Check, 12.0, color));
790 }
791 let title =
792 cx.child(&Text::new(self.title.clone()).style(TextStyle::Caption).foreground(ink).truncate());
793 let mut lines = vec![title];
794 if let Some(subtitle) = &self.detail {
795 lines.push(
796 cx.child(
797 &Text::new(subtitle.clone())
798 .style(TextStyle::Caption)
799 .font_weight(Weight::REGULAR)
800 .foreground(ink)
801 .truncate(),
802 ),
803 );
804 }
805 children.push(cx.container(
806 LayoutStyle {
807 grow: 1.0,
808 min_width: Some(0.0),
809 overflow_hidden: true,
810 ..LayoutStyle::stack(Axis::Vertical)
811 },
812 &lines,
813 Paint::None,
814 ));
815 if self.tinted.is_some() {
816 children.push(cx.container(
817 LayoutStyle {
818 absolute: Some((STRIPE_INSET, STRIPE_INSET)),
819 width: Some(STRIPE_WIDTH),
820 height: Some((self.frame.height() - 2.0 * STRIPE_INSET).max(0.0)),
821 ..LayoutStyle::leaf()
822 },
823 &[],
824 Paint::Quad(QuadPaint { corner_radius: STRIPE_WIDTH / 2.0, ..QuadPaint::new(color) }),
825 ));
826 }
827 let bar_point = Point::new(self.frame.x(), self.frame.y());
828 if self.editable && !e.locked && e.resizable {
829 for kind in [DragKind::ResizeStart, DragKind::ResizeEnd] {
830 let handle = EndHandle {
831 event: e.clone(),
832 kind,
833 editing: self.editing.clone(),
834 bar: bar_point,
835 width: self.frame.width(),
836 height: self.frame.height(),
837 };
838 children.push(cx.child(&handle));
839 }
840 }
841 let border = (self.selected || self.dragging.is_some())
842 .then(|| Border { width: 1.5, color: theme.color(Semantic::Ink).with_alpha(0.55) });
843 let node = cx.container(
844 LayoutStyle {
845 absolute: Some((self.frame.x(), self.frame.y())),
846 width: Some(self.frame.width()),
847 height: Some(self.frame.height()),
848 padding: if self.tinted.is_some() {
849 Insets { top: 0.0, bottom: 0.0, left: 11.0, right: 6.0 }
850 } else {
851 Insets::symmetric(0.0, 8.0)
852 },
853 spacing: 4.0,
854 align: Align::Center,
855 overflow_hidden: true,
856 ..LayoutStyle::stack(Axis::Horizontal)
857 },
858 &children,
859 Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, border, ..QuadPaint::new(fill) }),
860 );
861 let labels = cx.labels();
862 let range = time_range(&labels, self.editing.axis.scale, e.start, e.end);
863 cx.semantics(
864 node,
865 Semantics::new(Role::Button).name(e.title.clone()).description(range).selected(self.selected),
866 );
867 let menu_at: State<Option<Point>> = cx.use_state(|| None);
868 if let Some(menu) = self.menu.clone() {
869 let (write, selection, id) = (menu_at.clone(), self.selection.clone(), e.id);
870 cx.on_context_menu(
871 node,
872 Rc::new(move |at| {
873 if let Some(selection) = &selection {
874 selection.set(Some(id));
875 }
876 write.set(Some(at));
877 }),
878 );
879 if let Some(at) = menu_at.get() {
880 let close = menu_at.clone();
881 open_menu(cx, node, at, menu(e.id), Rc::new(move || close.set(None)));
882 }
883 }
884 if self.tinted.is_some() && menu_at.get().is_none() && (cx.is_hovered() || self.dragging.is_some()) {
886 let tip = EventTip {
887 title: &e.title,
888 when: time_range(&labels, self.editing.axis.scale, self.at.0, self.at.1),
889 detail: self.detail.as_deref(),
890 };
891 cx.overlay(node, Placement::AboveCenter, &tip, None);
892 }
893 if cx.is_focus_visible() {
894 let (from, to) = (self.frame.x(), self.frame.x() + self.frame.width());
895 let scrolled = self.editing.scroll.get();
896 if from < scrolled || to > scrolled + self.editing.view_width.get() {
897 self.editing.reveal.set(Some((from, to)));
898 let now = cx.now();
899 cx.wake_at(now);
900 }
901 }
902 if self.tab_stop {
903 cx.focusable(node, theme.radii.sm);
904 cx.on_key(node, self.key_handler());
905 }
906 let (selection, id) = (self.selection.clone(), e.id);
907 cx.on_click(
908 node,
909 Rc::new(move || {
910 if let Some(selection) = &selection {
911 selection.set(Some(id));
912 }
913 }),
914 );
915 if let Some(open) = self.on_open.clone() {
916 cx.on_double_click(node, Rc::new(move || open(id)));
917 }
918 if self.editable && !e.locked {
919 let origin = cx.use_state(|| Rc::new(Cell::new(Point::ZERO))).get();
920 let grab = cx.use_state(|| Rc::new(Cell::new(0_i64))).get();
921 let (event, editing) = (e.clone(), self.editing.clone());
922 cx.on_drag(
923 node,
924 Rc::new(move |drag: Drag| {
925 if drag.phase == DragPhase::Start {
926 let inside = drag.position.x - drag.frame.x();
928 let minutes = (f64::from(inside)
929 / f64::from(editing.axis.x_of(editing.axis.start.plus_days(1)).max(0.001))
930 * 1440.0) as i64;
931 grab.set(minutes);
932 }
933 let kind = DragKind::Move { grab: grab.get() };
934 let content_origin = Point::ZERO;
935 editing.follow(&event, kind, &origin, drag, content_origin);
936 }),
937 );
938 }
939 cx.cursor(node, CursorIcon::Pointer);
940 node
941 }
942}
943
944struct Lanes {
946 width: f32,
948 height: f32,
949 children: Rc<Vec<NodeId>>,
950 draw: Option<Rc<dyn Fn(Drag)>>,
951}
952
953#[cfg_attr(feature = "live", ravenrs_live::expose)]
954impl Lanes {
955 fn build(&self, cx: &mut BuildCx<'_>, s: &Scheduler, editing: &Editing) -> NodeId {
956 let node = cx.container(
957 LayoutStyle {
958 width: Some(self.width),
959 height: Some(self.height),
960 ..LayoutStyle::stack(Axis::Vertical)
961 },
962 &self.children,
963 Paint::None,
964 );
965 if let Some(menu) = s.lane_menu.clone() {
966 let open: State<Option<(Point, SchedulerChange)>> = cx.use_state(|| None);
967 let (write, editor) = (open.clone(), editing.clone());
968 cx.on_context_menu_in(
969 node,
970 Rc::new(move |position, frame| {
971 let at = Point::new(position.x - frame.x(), position.y - frame.y());
972 let step = editor.axis.snap_minutes();
974 let start = editor.axis.snap(editor.axis.time_at(at.x).plus_minutes(-step / 2));
975 let Some(resource) = editor.resource_at(at.y) else { return };
976 let end = start.plus_minutes(step);
977 write.set(Some((position, SchedulerChange { id: None, resource, start, end })));
978 }),
979 );
980 if let Some((at, change)) = open.get() {
981 let close = open.clone();
982 open_menu(cx, node, at, menu(change), Rc::new(move || close.set(None)));
983 }
984 }
985 if let Some(draw) = self.draw.clone() {
986 cx.on_drag(node, draw);
987 }
988 node
989 }
990}
991
992fn drawn(editor: &Editing, begin: TimePoint, resource: u64, time: TimePoint) -> SchedulerChange {
994 let step = editor.axis.snap_minutes();
995 let (start, end) =
996 if time >= begin { (begin, time.max(begin.plus_minutes(step))) } else { (time, begin) };
997 SchedulerChange { id: None, resource, start, end }
998}
999
1000fn draw_handler(cx: &mut BuildCx<'_>, s: &Scheduler, editing: &Editing) -> Option<Rc<dyn Fn(Drag)>> {
1002 if s.read_only || s.on_create.is_none() {
1003 return None;
1004 }
1005 let from: Rc<Cell<Option<(TimePoint, u64)>>> = cx.use_state(|| Rc::new(Cell::new(None))).get();
1006 let editor = editing.clone();
1007 Some(Rc::new(move |drag: Drag| {
1008 let at = Point::new(drag.position.x - drag.frame.x(), drag.position.y - drag.frame.y());
1009 let time = editor.axis.snap(editor.axis.time_at(at.x));
1010 if drag.phase == DragPhase::Start
1011 && let Some(resource) = editor.resource_at(at.y)
1012 {
1013 from.set(Some((time, resource)));
1014 }
1015 let Some((begin, resource)) = from.get() else { return };
1016 editor.propose(drawn(&editor, begin, resource, time));
1017 if drag.phase == DragPhase::End {
1018 from.set(None);
1019 editor.stop_tracking();
1020 editor.commit(None);
1021 } else {
1022 let again = editor.clone();
1023 editor.track(
1024 at,
1025 Rc::new(move |at| {
1026 let time = again.axis.snap(again.axis.time_at(at.x));
1027 again.propose(drawn(&again, begin, resource, time));
1028 }),
1029 );
1030 }
1031 }))
1032}
1033
1034fn time_label(labels: &Labels, scale: TimeScale, time: TimePoint) -> String {
1036 let date = labels.short_date(time.date());
1037 match scale {
1038 TimeScale::Hours => {
1039 let (hour, minute) = time.hour_minute();
1040 format!("{date} {hour:02}:{minute:02}")
1041 }
1042 _ => date,
1043 }
1044}
1045
1046fn time_range(labels: &Labels, scale: TimeScale, start: TimePoint, end: TimePoint) -> String {
1048 let last = if scale == TimeScale::Hours { end } else { end.plus_minutes(-1).max(start) };
1049 let (from, to) = (time_label(labels, scale, start), time_label(labels, scale, last));
1050 if from == to { from } else { format!("{from} – {to}") }
1051}
1052
1053fn tick_text(labels: &Labels, scale: TimeScale, time: TimePoint) -> String {
1054 let date = time.date();
1055 match scale {
1056 TimeScale::Hours => format!("{:02}", time.hour_minute().0),
1057 TimeScale::Days => date.day().to_string(),
1058 TimeScale::Weeks => format!("{:02}", date.day()),
1059 TimeScale::Months => {
1060 let name = &labels.months[usize::from(date.month().clamp(1, 12)) - 1];
1061 name.chars().take(3).collect()
1062 }
1063 }
1064}
1065
1066fn group_text(labels: &Labels, scale: TimeScale, time: TimePoint) -> String {
1067 let date = time.date();
1068 match scale {
1069 TimeScale::Hours => labels.short_date(date),
1070 TimeScale::Days | TimeScale::Weeks => labels.month_title(date),
1071 TimeScale::Months => date.year().to_string(),
1072 }
1073}
1074
1075fn band(cx: &mut BuildCx<'_>, frame: Rect, fill: ravenrs_core::Color) -> NodeId {
1077 let node = cx.container(
1078 LayoutStyle {
1079 absolute: Some((frame.x(), frame.y())),
1080 width: Some(frame.width()),
1081 height: Some(frame.height()),
1082 ..LayoutStyle::leaf()
1083 },
1084 &[],
1085 Paint::Quad(QuadPaint::new(fill)),
1086 );
1087 cx.semantics(node, Semantics::hidden());
1088 node
1089}
1090
1091fn placed_text(
1093 cx: &mut BuildCx<'_>,
1094 frame: Rect,
1095 content: &str,
1096 style: TextStyle,
1097 color: ravenrs_core::Color,
1098) -> NodeId {
1099 let label = util::label(cx, content, style, None, color);
1100 cx.container(
1101 LayoutStyle {
1102 absolute: Some((frame.x(), frame.y())),
1103 width: Some(frame.width()),
1104 height: Some(frame.height()),
1105 align: Align::Center,
1106 justify: Justify::Center,
1107 overflow_hidden: true,
1108 ..LayoutStyle::stack(Axis::Horizontal)
1109 },
1110 &[label],
1111 Paint::None,
1112 )
1113}
1114
1115#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
1116impl Scheduler {
1117 fn lane_height(&self) -> f32 {
1119 if self.event_detail.is_some() { TALL_LANE_HEIGHT } else { LANE_HEIGHT }
1120 }
1121
1122 fn rows(&self, lanes_of: &HashMap<u64, usize>, collapsed: &BTreeSet<String>) -> (Vec<Row>, f32) {
1123 let mut order: Vec<Option<&str>> = Vec::new();
1124 for resource in &self.resources {
1125 let group = resource.group.as_deref();
1126 if !order.contains(&group) {
1127 order.push(group);
1128 }
1129 }
1130 let mut rows = Vec::with_capacity(self.resources.len() + order.len());
1131 let mut y = 0.0;
1132 for group in order {
1133 let members: Vec<usize> =
1134 (0..self.resources.len()).filter(|i| self.resources[*i].group.as_deref() == group).collect();
1135 let mut hidden = false;
1136 if let Some(name) = group {
1137 hidden = collapsed.contains(name);
1138 rows.push(Row::Group { name: name.to_owned(), count: members.len(), collapsed: hidden, y });
1139 y += GROUP_HEIGHT;
1140 }
1141 if hidden {
1142 continue;
1143 }
1144 for index in members {
1145 let lanes = lanes_of.get(&self.resources[index].id).copied().unwrap_or(1);
1146 let height = lanes as f32 * self.lane_height() + 2.0 * ROW_PADDING;
1147 rows.push(Row::Resource { index, y, height });
1148 y += height;
1149 }
1150 }
1151 (rows, y)
1152 }
1153
1154 fn toolbar_view(&self, cx: &mut BuildCx<'_>, axis: TimeAxis) -> NodeId {
1155 let labels = cx.labels();
1156 let (back, ahead, zoom_in, zoom_out) =
1157 (self.axis.clone(), self.axis.clone(), self.axis.clone(), self.axis.clone());
1158 let mut items = Vec::new();
1159 let today_button = self.now.map(|now| {
1160 let today = self.axis.clone();
1161 cx.child(
1162 &Button::new(labels.today.clone())
1163 .style(if self.card { ButtonStyle::Secondary } else { ButtonStyle::Ghost })
1164 .size(ButtonSize::Small)
1165 .on_click(move || {
1166 let length = axis.end.minutes() - axis.start.minutes();
1167 let start = axis.snap(now.plus_minutes(-length / 8));
1168 today.set(TimeAxis { start, end: start.plus_minutes(length), ..axis });
1169 }),
1170 )
1171 });
1172 if self.card {
1174 items.extend(today_button);
1175 }
1176 items.push(
1177 cx.child(
1178 &IconButton::new(IconName::ChevronLeft)
1179 .label(labels.previous_month.clone())
1180 .size(ButtonSize::Small)
1181 .on_click(move || back.set(axis.shifted(-1))),
1182 ),
1183 );
1184 items.push(
1185 cx.child(
1186 &IconButton::new(IconName::ChevronRight)
1187 .label(labels.next_month.clone())
1188 .size(ButtonSize::Small)
1189 .on_click(move || ahead.set(axis.shifted(1))),
1190 ),
1191 );
1192 if !self.card {
1193 let title = format!(
1194 "{} – {}",
1195 labels.short_date(axis.start.date()),
1196 labels.short_date(axis.end.plus_minutes(-1).date())
1197 );
1198 items.push(util::label(cx, &title, TextStyle::Headline, None, cx.theme().color(Semantic::Ink)));
1199 items.extend(today_button);
1200 }
1201 items.push(cx.container(LayoutStyle { grow: 1.0, ..LayoutStyle::leaf() }, &[], Paint::None));
1202 let mut out =
1203 IconButton::new(IconName::ZoomOut).label(labels.zoom_out.clone()).size(ButtonSize::Small);
1204 if let Some(farther) = axis.zoomed_out() {
1205 out = out.on_click(move || zoom_out.set(farther));
1206 }
1207 items.push(cx.child(&out));
1208 let mut near =
1209 IconButton::new(IconName::ZoomIn).label(labels.zoom_in.clone()).size(ButtonSize::Small);
1210 if let Some(nearer) = axis.zoomed_in() {
1211 near = near.on_click(move || zoom_in.set(nearer));
1212 }
1213 items.push(cx.child(&near));
1214 cx.container(
1215 LayoutStyle {
1216 spacing: 6.0,
1217 align: Align::Center,
1218 padding: if self.card { Insets::symmetric(10.0, 16.0) } else { Insets::symmetric(8.0, 8.0) },
1219 ..LayoutStyle::stack(Axis::Horizontal)
1220 },
1221 &items,
1222 Paint::None,
1223 )
1224 }
1225}
1226
1227impl View for Scheduler {
1228 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1229 let theme = *cx.theme();
1230 let labels = cx.labels();
1231 let axis = self.axis.get();
1232 let preview: State<Option<Preview>> = cx.use_state(|| None);
1233 let collapsed: State<BTreeSet<String>> = cx.use_state(BTreeSet::new);
1234 let own_selection: State<Option<u64>> = cx.use_state(|| None);
1235 let selection = self.selected.clone().unwrap_or_else(|| own_selection.binding());
1236 let selected = selection.get();
1237 let current = preview.get();
1238
1239 let placed: Vec<(usize, SchedulerChange)> = self
1241 .events
1242 .iter()
1243 .enumerate()
1244 .map(|(i, e)| {
1245 let here =
1246 SchedulerChange { id: Some(e.id), resource: e.resource, start: e.start, end: e.end };
1247 (i, current.filter(|p| p.change.id == Some(e.id)).map_or(here, |p| p.change))
1248 })
1249 .collect();
1250 let mut by_resource: HashMap<u64, Vec<usize>> = HashMap::new();
1251 for (slot, (_, change)) in placed.iter().enumerate() {
1252 by_resource.entry(change.resource).or_default().push(slot);
1253 }
1254 let mut lane_of = vec![0_usize; placed.len()];
1255 let mut lanes_of: HashMap<u64, usize> = HashMap::new();
1256 for (resource, slots) in &by_resource {
1257 let spans: Vec<(TimePoint, TimePoint)> =
1258 slots.iter().map(|s| (placed[*s].1.start, placed[*s].1.end)).collect();
1259 let (lanes, count) = assign_lanes(&spans);
1260 for (slot, lane) in slots.iter().zip(lanes) {
1261 lane_of[*slot] = lane;
1262 }
1263 lanes_of.insert(*resource, count);
1264 }
1265 let lane_height = self.lane_height();
1266 let (mut rows, mut total_height) = self.rows(&lanes_of, &collapsed.get());
1267 if self.fill_rows {
1268 match cx.scroll_viewport() {
1269 Some(view) => total_height = share_height(&mut rows, total_height, view.height),
1270 None => {
1272 let now = cx.now();
1273 cx.wake_at(now);
1274 }
1275 }
1276 }
1277 let top_of: HashMap<u64, f32> = rows
1278 .iter()
1279 .filter_map(|row| match row {
1280 Row::Resource { index, y, .. } => Some((self.resources[*index].id, *y)),
1281 Row::Group { .. } => None,
1282 })
1283 .collect();
1284 let row_lines: Rc<Vec<(u64, f32, f32)>> = Rc::new(
1285 rows.iter()
1286 .filter_map(|row| match row {
1287 Row::Resource { index, y, height, .. } => Some((self.resources[*index].id, *y, *height)),
1288 Row::Group { .. } => None,
1289 })
1290 .collect(),
1291 );
1292
1293 let scroll = cx.scroll_offset();
1294 let window = cx.viewport();
1295 let drag_scroll: Rc<Cell<f32>> = cx.use_state(|| Rc::new(Cell::new(0.0))).get();
1296 drag_scroll.set(scroll.x);
1297 let view_width: Rc<Cell<f32>> = cx.use_state(|| Rc::new(Cell::new(0.0))).get();
1298 let reveal: Rc<Cell<Option<(f32, f32)>>> = cx.use_state(|| Rc::new(Cell::new(None))).get();
1299 let drag_pointer: Rc<Cell<Option<(f32, f32)>>> = cx.use_state(|| Rc::new(Cell::new(None))).get();
1300 let drag_follow: FollowOn = cx.use_state(|| Rc::new(RefCell::new(None))).get();
1301 let opened: Rc<Cell<bool>> = cx.use_state(|| Rc::new(Cell::new(false))).get();
1303 if !opened.get() && self.now.is_some() {
1304 match cx.scroll_viewport() {
1305 Some(size) => {
1306 opened.set(true);
1307 if let Some(now) = self.now {
1308 let lead = if self.card { 0.0 } else { size.width / 4.0 };
1309 cx.set_scroll_offset(Point::new((axis.x_of(now) - lead).max(0.0), 0.0));
1310 }
1311 }
1312 None => {
1314 let now = cx.now();
1315 cx.wake_at(now);
1316 }
1317 }
1318 }
1319 let view = cx.scroll_viewport().unwrap_or(window);
1320 let (left, right) = (scroll.x - OVERSCAN, scroll.x + view.width + OVERSCAN);
1321 let (top, bottom) = (scroll.y - OVERSCAN, scroll.y + view.height + OVERSCAN);
1322 let width = axis.width();
1323 let line = theme.color(Semantic::Border);
1324 let muted = theme.color(Semantic::InkMuted);
1325 let ink = theme.color(Semantic::Ink);
1326
1327 let editing = Editing {
1328 axis,
1329 rows: row_lines,
1330 preview: preview.clone(),
1331 on_change: self.on_change.clone(),
1332 on_create: self.on_create.clone(),
1333 can_drop: self.can_drop.clone(),
1334 scroll: drag_scroll.clone(),
1335 pointer: drag_pointer.clone(),
1336 follow_on: drag_follow.clone(),
1337 view_width: view_width.clone(),
1338 reveal: reveal.clone(),
1339 };
1340
1341 view_width.set(view.width);
1342 if let Some((from, to)) = reveal.take() {
1344 let wanted = if from < scroll.x { from - REVEAL_MARGIN } else { to + REVEAL_MARGIN - view.width };
1345 cx.set_scroll_offset(Point::new(wanted.clamp(0.0, (width - view.width).max(0.0)), scroll.y));
1346 }
1347
1348 if let Some((x, y)) = drag_pointer.get() {
1350 let push = if x > view.width - AUTO_SCROLL_EDGE {
1351 AUTO_SCROLL_STEP
1352 } else if x < AUTO_SCROLL_EDGE {
1353 -AUTO_SCROLL_STEP
1354 } else {
1355 0.0
1356 };
1357 let moved = (scroll.x + push).clamp(0.0, (width - view.width).max(0.0));
1358 if moved != scroll.x {
1359 cx.set_scroll_offset(Point::new(moved, scroll.y));
1360 drag_scroll.set(moved);
1361 let follow_on = drag_follow.borrow().clone();
1362 if let Some(follow_on) = follow_on {
1363 follow_on(Point::new(x + moved, y));
1364 }
1365 let now = cx.now();
1366 cx.wake_at(now + AUTO_SCROLL_EVERY);
1367 }
1368 }
1369
1370 let ticks = axis.ticks(left, right);
1372 let mut header_cells = Vec::new();
1373 let mut previous = None;
1374 let accent = theme.color(Semantic::Accent);
1375 let today = self.now.map(|now| now.date());
1376 let header_height = 2.0 * HEADER_ROW_HEIGHT;
1377 for (i, tick) in ticks.iter().enumerate() {
1378 if axis.starts_group(tick.start, previous) {
1379 let until = ticks[i + 1..]
1380 .iter()
1381 .find(|t| axis.starts_group(t.start, Some(tick.start)))
1382 .map_or(right.min(width), |t| t.x);
1383 let label = group_text(&labels, axis.scale, tick.start);
1384 let cell = util::label(cx, &label, TextStyle::Caption, Some(Weight::SEMIBOLD), ink);
1385 let x = (tick.x.max(scroll.x) + 6.0).min(until);
1387 header_cells.push(cx.container(
1388 LayoutStyle {
1389 absolute: Some((x, 0.0)),
1390 width: Some((until - x).max(0.0)),
1391 height: Some(HEADER_ROW_HEIGHT),
1392 align: Align::Center,
1393 overflow_hidden: true,
1394 ..LayoutStyle::stack(Axis::Horizontal)
1395 },
1396 &[cell],
1397 Paint::None,
1398 ));
1399 }
1400 previous = Some(tick.start);
1401 let text = if self.card && axis.scale == TimeScale::Days {
1402 let date = tick.start.date();
1403 format!("{} {}", labels.weekdays[usize::from(date.weekday()).min(6)], date.day())
1404 } else {
1405 tick_text(&labels, axis.scale, tick.start)
1406 };
1407 let frame = Rect::new(tick.x, HEADER_ROW_HEIGHT, tick.width, HEADER_ROW_HEIGHT);
1408 let is_today = self.card && axis.scale == TimeScale::Days && today == Some(tick.start.date());
1409 header_cells.push(placed_text(
1410 cx,
1411 frame,
1412 &text,
1413 TextStyle::Caption,
1414 if is_today { accent } else { muted },
1415 ));
1416 if is_today {
1417 header_cells.push(band(cx, Rect::new(tick.x, header_height - 2.0, tick.width, 2.0), accent));
1419 }
1420 header_cells.push(band(cx, Rect::new(tick.x, HEADER_ROW_HEIGHT, 1.0, HEADER_ROW_HEIGHT), line));
1421 }
1422 let header_paint = Paint::Quad(QuadPaint::new(theme.color(if self.card {
1424 Semantic::Canvas
1425 } else {
1426 Semantic::Surface
1427 })));
1428 let header_row = cx.container(
1429 LayoutStyle {
1430 width: Some(width),
1431 height: Some(header_height),
1432 ..LayoutStyle::stack(Axis::Vertical)
1433 },
1434 &header_cells,
1435 Paint::None,
1436 );
1437 cx.translate(header_row, Point::new(-scroll.x, 0.0));
1438 let header_view = cx.container(
1439 LayoutStyle {
1440 grow: 1.0,
1441 min_width: Some(0.0),
1442 height: Some(header_height),
1443 overflow_hidden: true,
1444 ..LayoutStyle::stack(Axis::Horizontal)
1445 },
1446 &[header_row],
1447 header_paint.clone(),
1448 );
1449 cx.clip(header_view);
1450 let corner_text =
1451 util::label(cx, &labels.scheduler_resources, TextStyle::Caption, Some(Weight::SEMIBOLD), muted);
1452 let corner = cx.container(
1453 LayoutStyle {
1454 width: Some(self.resource_width),
1455 height: Some(header_height),
1456 padding: Insets::symmetric(0.0, 10.0),
1457 align: Align::Center,
1458 ..LayoutStyle::stack(Axis::Horizontal)
1459 },
1460 &[corner_text],
1461 header_paint,
1462 );
1463 let divider = |cx: &mut BuildCx<'_>| {
1465 cx.container(
1466 LayoutStyle { width: Some(1.0), ..LayoutStyle::leaf() },
1467 &[],
1468 Paint::Quad(QuadPaint::new(line)),
1469 )
1470 };
1471 let mut header_parts = vec![corner];
1472 if self.card {
1473 header_parts.push(divider(cx));
1474 }
1475 header_parts.push(header_view);
1476 let header = cx.container(
1477 LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Horizontal) },
1478 &header_parts,
1479 Paint::None,
1480 );
1481
1482 let draw = draw_handler(cx, self, &editing);
1483 let mut under = Vec::new();
1485 let area = if self.card { total_height.max(view.height) } else { total_height };
1487 let lines_height = if self.card { area - theme.radii.xl } else { area };
1488 if self.card && axis.scale == TimeScale::Days {
1489 for tick in ticks.iter().filter(|tick| today == Some(tick.start.date())) {
1490 under.push(band(
1491 cx,
1492 Rect::new(tick.x, 0.0, tick.width, lines_height),
1493 accent.with_alpha(TODAY_TINT),
1494 ));
1495 }
1496 }
1497 if axis.scale == TimeScale::Days || axis.scale == TimeScale::Hours {
1498 for tick in &ticks {
1499 let weekday = tick.start.date().weekday();
1500 if weekday == 0 || weekday == 6 {
1501 under.push(band(
1502 cx,
1503 Rect::new(tick.x, 0.0, tick.width, lines_height),
1504 ink.with_alpha(0.035),
1505 ));
1506 }
1507 }
1508 }
1509
1510 for tick in &ticks {
1511 under.push(band(cx, Rect::new(tick.x, 0.0, 1.0, lines_height), line.faded(0.5)));
1512 }
1513 for range in &self.ranges {
1514 let (from, to) = (axis.x_of(range.start), axis.x_of(range.end));
1515 if to > left && from < right {
1516 let color = range.tone.color(&theme).with_alpha(0.12);
1517 under.push(band(cx, Rect::new(from, 0.0, (to - from).max(1.0), total_height), color));
1518 }
1519 }
1520 let mut names = Vec::new();
1521 for row in rows.iter().filter(|r| r.y() + r.height() > top && r.y() < bottom) {
1522 let y = row.y();
1523 if self.card
1524 && let Row::Resource { index, height, .. } = row
1525 {
1526 let band = RowBand { frame: Rect::new(0.0, y, width, *height), draw: draw.clone() };
1527 let key = 0x726f_7700_0000_0000 ^ self.resources[*index].id;
1528 under.insert(0, cx.keyed_child(key, &band));
1529 }
1530 if !self.card || y + row.height() < area {
1531 under.push(band(cx, Rect::new(0.0, y + row.height() - 1.0, width, 1.0), line));
1532 }
1533 match row {
1534 Row::Group { name, count, collapsed: is_collapsed, .. } => {
1535 under.push(band(cx, Rect::new(0.0, y, width, GROUP_HEIGHT), ink.with_alpha(0.03)));
1536 let chevron = if *is_collapsed { IconName::ChevronRight } else { IconName::ChevronDown };
1537 let icon = util::icon(cx, chevron, 14.0, muted);
1538 let label = util::label(cx, name, TextStyle::Caption, Some(Weight::SEMIBOLD), ink);
1539 let note = util::label(cx, &count.to_string(), TextStyle::Caption, None, muted);
1540 let node = cx.keyed_child(
1541 0x6772_6f75_7000_0000 ^ hash_text(name),
1542 &GroupHeader {
1543 name: name.clone(),
1544 collapsed: *is_collapsed,
1545 state: collapsed.clone(),
1546 children: Rc::new(vec![icon, label, note]),
1547 frame: Rect::new(0.0, y, self.resource_width, GROUP_HEIGHT),
1548 },
1549 );
1550 names.push(node);
1551 }
1552 Row::Resource { index, height, .. } => {
1553 let resource = &self.resources[*index];
1554 let mut name = Text::new(resource.name.clone()).style(TextStyle::Callout).truncate();
1555 if self.card {
1556 name = name.font_weight(Weight::SEMIBOLD);
1557 }
1558 let name = cx.child(&name);
1559 let avatar = self
1560 .avatars
1561 .then(|| cx.child(&crate::Avatar::new(resource.name.clone()).size(AVATAR_SIZE)));
1562 let mut lines = vec![name];
1563 if let Some(subtitle) = &resource.subtitle {
1564 lines.push(
1565 cx.child(
1566 &Text::new(subtitle.clone())
1567 .style(TextStyle::Caption)
1568 .foreground(Semantic::InkMuted)
1569 .truncate(),
1570 ),
1571 );
1572 }
1573 let row_parts = match avatar {
1574 Some(avatar) => {
1575 let text = cx.container(
1576 LayoutStyle {
1577 grow: 1.0,
1578 min_width: Some(0.0),
1579 align: Align::Stretch,
1580 ..LayoutStyle::stack(Axis::Vertical)
1581 },
1582 &lines,
1583 Paint::None,
1584 );
1585 vec![avatar, text]
1586 }
1587 None => lines,
1588 };
1589 let node = cx.container(
1590 LayoutStyle {
1591 absolute: Some((0.0, y)),
1592 width: Some(self.resource_width),
1593 height: Some(*height),
1594 padding: if self.fill_rows {
1595 Insets { top: NAME_TOP, right: 10.0, bottom: 0.0, left: 10.0 }
1596 } else {
1597 Insets::symmetric(0.0, 10.0)
1598 },
1599 justify: if self.fill_rows || self.avatars {
1600 Justify::Start
1601 } else {
1602 Justify::Center
1603 },
1604 spacing: AVATAR_GAP,
1605 align: if self.avatars { Align::Center } else { Align::Stretch },
1606 overflow_hidden: true,
1607 ..LayoutStyle::stack(if self.avatars { Axis::Horizontal } else { Axis::Vertical })
1608 },
1609 &row_parts,
1610 Paint::None,
1611 );
1612 cx.semantics(node, Semantics::new(Role::Row).name(resource.name.clone()));
1613 names.push(node);
1614 if !self.card || y + height < area {
1615 names.push(band(
1616 cx,
1617 Rect::new(0.0, y + height - 1.0, self.resource_width, 1.0),
1618 line,
1619 ));
1620 }
1621 }
1622 }
1623 }
1624
1625 let tab_stop = selected
1627 .filter(|id| self.events.iter().any(|e| e.id == *id))
1628 .or_else(|| self.events.first().map(|e| e.id));
1629 let mut bars = Vec::new();
1630 for (slot, (index, change)) in placed.iter().enumerate() {
1631 let Some(row_top) = top_of.get(&change.resource) else { continue };
1632 let event = &self.events[*index];
1633 let x = axis.x_of(change.start);
1634 let bar_width = (axis.x_of(change.end) - x).max(MIN_BAR_WIDTH);
1635 let y = row_top + ROW_PADDING + lane_of[slot] as f32 * lane_height + 2.0;
1636 let is_tab_stop = tab_stop == Some(event.id);
1637 let in_view = x + bar_width > left && x < right && y + lane_height > top && y < bottom;
1638 if !in_view && !is_tab_stop {
1640 continue;
1641 }
1642 let dragging = current.filter(|p| p.change.id == Some(event.id)).map(|p| p.allowed);
1643 let title = match dragging {
1644 Some(_) => time_range(&labels, axis.scale, change.start, change.end),
1645 None => event.title.clone(),
1646 };
1647 let bar = Bar {
1648 detail: self.event_detail.as_ref().and_then(|detail| detail(event.id)),
1649 at: (change.start, change.end),
1650 menu: self.event_menu.clone(),
1651 tinted: self.card.then(|| theme.color(Semantic::SurfaceElevated)),
1652 event,
1653 frame: if self.card {
1654 Rect::new(
1656 x + CARD_BAR_GAP,
1657 y,
1658 (bar_width - 2.0 * CARD_BAR_GAP).max(MIN_BAR_WIDTH),
1659 lane_height - 4.0,
1660 )
1661 } else {
1662 Rect::new(x, y, bar_width, lane_height - 4.0)
1663 },
1664 title,
1665 dragging,
1666 selected: selected == Some(event.id),
1667 tab_stop: is_tab_stop,
1668 editable: !self.read_only && self.on_change.is_some(),
1669 editing: editing.clone(),
1670 selection: Some(selection.clone()),
1671 on_open: self.on_open.clone(),
1672 on_delete: self.on_delete.clone(),
1673 };
1674 bars.push(cx.keyed_child(event.id, &bar));
1675 }
1676 if let Some(Preview { change, allowed }) = current.filter(|p| p.change.id.is_none())
1678 && let Some(row_top) = top_of.get(&change.resource)
1679 {
1680 let x = axis.x_of(change.start);
1681 let frame = Rect::new(
1682 x,
1683 row_top + ROW_PADDING + 2.0,
1684 (axis.x_of(change.end) - x).max(MIN_BAR_WIDTH),
1685 lane_height - 4.0,
1686 );
1687 let color = if allowed { theme.color(Semantic::Accent) } else { theme.color(Semantic::Danger) };
1688 bars.push(band(cx, frame, color.with_alpha(0.45)));
1689 }
1690 if let Some(now) = self.now.filter(|_| !self.card) {
1691 let x = axis.x_of(now);
1692 if x >= 0.0 && x <= width {
1693 bars.push(band(cx, Rect::new(x, 0.0, 2.0, total_height), theme.color(Semantic::Danger)));
1694 }
1695 }
1696 under.extend(bars);
1697
1698 let lanes = Lanes { width, height: area, children: Rc::new(under), draw };
1699 let content = lanes.build(cx, self, &editing);
1700 let body = cx.container(
1701 LayoutStyle {
1702 grow: 1.0,
1703 min_width: Some(0.0),
1704 min_height: Some(0.0),
1705 overflow_hidden: true,
1706 ..LayoutStyle::stack(Axis::Vertical)
1707 },
1708 &[content],
1709 Paint::None,
1710 );
1711 cx.scrollable_virtual(body, true);
1712 let (pinched, zooming): (Rc<Cell<f32>>, _) =
1713 (cx.use_state(|| Rc::new(Cell::new(0.0))).get(), self.axis.clone());
1714 cx.on_pinch(
1715 body,
1716 Rc::new(move |delta| {
1717 pinched.set(pinched.get() + delta);
1718 if pinched.get().abs() < PINCH_STEP {
1719 return;
1720 }
1721 let current = zooming.get();
1722 let next = if pinched.get() > 0.0 { current.zoomed_in() } else { current.zoomed_out() };
1723 pinched.set(0.0);
1724 if let Some(next) = next {
1725 zooming.set(next);
1726 }
1727 }),
1728 );
1729 cx.semantics(body, Semantics::new(Role::Grid).name(labels.scheduler_resources.clone()));
1730
1731 let names_content = cx.container(
1733 LayoutStyle {
1734 width: Some(self.resource_width),
1735 height: Some(total_height),
1736 ..LayoutStyle::stack(Axis::Vertical)
1737 },
1738 &names,
1739 Paint::None,
1740 );
1741 cx.translate(names_content, Point::new(0.0, -scroll.y));
1742 let names_view = cx.container(
1743 LayoutStyle {
1744 width: Some(self.resource_width),
1745 min_height: Some(0.0),
1746 overflow_hidden: true,
1747 ..LayoutStyle::stack(Axis::Vertical)
1748 },
1749 &[names_content],
1750 Paint::None,
1751 );
1752 cx.clip(names_view);
1753 cx.scroll_with_widget(names_view);
1754 let mut middle_parts = vec![names_view];
1755 if self.card {
1756 middle_parts.push(divider(cx));
1757 }
1758 middle_parts.push(body);
1759 let middle = cx.container(
1760 LayoutStyle {
1761 grow: 1.0,
1762 min_height: Some(0.0),
1763 align: Align::Stretch,
1764 overflow_hidden: true,
1765 ..LayoutStyle::stack(Axis::Horizontal)
1766 },
1767 &middle_parts,
1768 Paint::None,
1769 );
1770
1771 let hairline = |cx: &mut BuildCx<'_>| {
1772 cx.container(
1773 LayoutStyle { height: Some(1.0), ..LayoutStyle::leaf() },
1774 &[],
1775 Paint::Quad(QuadPaint::new(line)),
1776 )
1777 };
1778 let mut parts = Vec::new();
1779 if self.toolbar {
1780 parts.push(self.toolbar_view(cx, axis));
1781 if self.card {
1782 parts.push(hairline(cx));
1783 }
1784 }
1785 parts.push(header);
1786 if self.card {
1787 parts.push(hairline(cx));
1788 }
1789 parts.push(middle);
1790 let root = cx.container(
1791 LayoutStyle {
1792 grow: 1.0,
1793 min_height: Some(0.0),
1794 align: Align::Stretch,
1795 overflow_hidden: true,
1796 ..LayoutStyle::stack(Axis::Vertical)
1797 },
1798 &parts,
1799 Paint::Quad(QuadPaint {
1800 corner_radius: if self.card { theme.radii.xl } else { theme.radii.md },
1801 border: Some(Border { width: 1.0, color: line }),
1802 shadow: self.card.then_some(theme.elevation.soft),
1803 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
1804 }),
1805 );
1806 cx.clip(root);
1807 root
1808 }
1809}
1810
1811fn share_height(rows: &mut [Row], total: f32, available: f32) -> f32 {
1813 let count = rows.iter().filter(|row| matches!(row, Row::Resource { .. })).count();
1814 if count == 0 || available <= total {
1815 return total;
1816 }
1817 let extra = (available - total) / count as f32;
1818 let mut shift = 0.0;
1819 for row in rows {
1820 match row {
1821 Row::Group { y, .. } => *y += shift,
1822 Row::Resource { y, height, .. } => {
1823 *y += shift;
1824 *height += extra;
1825 shift += extra;
1826 }
1827 }
1828 }
1829 available
1830}
1831
1832fn hash_text(text: &str) -> u64 {
1833 text.bytes()
1834 .fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3))
1835}
1836
1837struct GroupHeader {
1838 name: String,
1839 collapsed: bool,
1840 state: State<BTreeSet<String>>,
1841 children: Rc<Vec<NodeId>>,
1842 frame: Rect,
1843}
1844
1845impl View for GroupHeader {
1846 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1847 let radius = cx.theme().radii.sm;
1848 let node = cx.container(
1849 LayoutStyle {
1850 absolute: Some((self.frame.x(), self.frame.y())),
1851 width: Some(self.frame.width()),
1852 height: Some(self.frame.height()),
1853 padding: Insets::symmetric(0.0, 8.0),
1854 spacing: 6.0,
1855 align: Align::Center,
1856 ..LayoutStyle::stack(Axis::Horizontal)
1857 },
1858 &self.children,
1859 Paint::None,
1860 );
1861 cx.semantics(node, Semantics::new(Role::Button).name(self.name.clone()).expanded(!self.collapsed));
1862 let (state, name) = (self.state.clone(), self.name.clone());
1863 cx.on_click(
1864 node,
1865 Rc::new(move || {
1866 state.update(|set| {
1867 if !set.remove(&name) {
1868 set.insert(name.clone());
1869 }
1870 });
1871 }),
1872 );
1873 cx.focusable(node, radius);
1874 cx.cursor(node, CursorIcon::Pointer);
1875 node
1876 }
1877}