1use crate::color::{ColorDepth, Rgb};
5use crate::event::{Event, MouseButton, MouseEvent, MouseKind};
6use crate::geometry::{Rect, Size, clamp_u16};
7use crate::icons::Glyph;
8use crate::style::CellStyle;
9use crate::text;
10use crate::theme::State;
11use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, PointerShape, Widget};
12
13use super::{click, close_mark};
14
15const MARKS: u16 = close_mark::WIDTH * 3;
17
18const TITLE_GAP: u16 = 2;
20
21const MIN_SUBTITLE: u16 = 4;
24
25const DEFAULT_SHADOW: u16 = 45;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum WindowEvent {
37 Focus,
40 Move {
43 dx: i32,
45 dy: i32,
47 },
48 Resize {
54 edge: WindowEdge,
56 dx: i32,
58 dy: i32,
60 },
61 Minimize,
63 ToggleMaximize,
65 Close,
67 Dropped,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct WindowDrag {
84 pub step: WindowEvent,
87 pub total_dx: i32,
90 pub total_dy: i32,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum WindowEdge {
98 Left,
100 Right,
102 Top,
104 Bottom,
106 TopLeft,
108 TopRight,
110 BottomLeft,
112 BottomRight,
114}
115
116impl WindowEdge {
117 #[must_use]
120 pub fn left(self) -> bool {
121 matches!(self, Self::Left | Self::TopLeft | Self::BottomLeft)
122 }
123
124 #[must_use]
126 pub fn right(self) -> bool {
127 matches!(self, Self::Right | Self::TopRight | Self::BottomRight)
128 }
129
130 #[must_use]
132 pub fn top(self) -> bool {
133 matches!(self, Self::Top | Self::TopLeft | Self::TopRight)
134 }
135
136 #[must_use]
138 pub fn bottom(self) -> bool {
139 matches!(self, Self::Bottom | Self::BottomLeft | Self::BottomRight)
140 }
141
142 fn pointer_shape(self) -> PointerShape {
144 match self {
145 Self::Left | Self::Right => PointerShape::EwResize,
146 Self::Top | Self::Bottom => PointerShape::NsResize,
147 Self::TopLeft | Self::BottomRight => PointerShape::NwseResize,
148 Self::TopRight | Self::BottomLeft => PointerShape::NeswResize,
149 }
150 }
151}
152
153type SideTest = fn(WindowEdge) -> bool;
155
156type EventMessage<Msg> = Box<dyn Fn(WindowEvent) -> Msg>;
158
159type DragMessage<Msg> = Box<dyn Fn(WindowDrag) -> Msg>;
161
162pub struct Window<Msg> {
201 title: String,
202 subtitle: Option<String>,
203 icon: Option<Glyph>,
204 focused: bool,
205 maximized: bool,
206 shadow: bool,
207 on_event: Option<EventMessage<Msg>>,
208 on_drag: Option<DragMessage<Msg>>,
209 body: Vec<Node<Msg>>,
211}
212
213impl<Msg: 'static> Window<Msg> {
214 #[must_use]
216 pub fn new(title: impl Into<String>) -> Self {
217 Self {
218 title: title.into(),
219 subtitle: None,
220 icon: None,
221 focused: false,
222 maximized: false,
223 shadow: false,
224 on_event: None,
225 on_drag: None,
226 body: vec![body(Vec::new())],
227 }
228 }
229
230 #[must_use]
232 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
233 self.subtitle = Some(subtitle.into());
234 self
235 }
236
237 #[must_use]
239 pub fn icon(mut self, glyph: impl Into<Glyph>) -> Self {
240 self.icon = Some(glyph.into());
241 self
242 }
243
244 #[must_use]
247 pub fn focused(mut self, focused: bool) -> Self {
248 self.focused = focused;
249 self
250 }
251
252 #[must_use]
254 pub fn maximized(mut self, maximized: bool) -> Self {
255 self.maximized = maximized;
256 self
257 }
258
259 #[must_use]
263 pub fn shadow(mut self, shadow: bool) -> Self {
264 self.shadow = shadow;
265 self
266 }
267
268 #[must_use]
271 pub fn on_event(mut self, message: impl Fn(WindowEvent) -> Msg + 'static) -> Self {
272 self.on_event = Some(Box::new(message));
273 self
274 }
275
276 #[must_use]
282 pub fn on_drag(mut self, message: impl Fn(WindowDrag) -> Msg + 'static) -> Self {
283 self.on_drag = Some(Box::new(message));
284 self
285 }
286}
287
288fn body<Msg: 'static>(children: Vec<Node<Msg>>) -> Node<Msg> {
289 let mut column = Node::new(Flex::new(Axis::Column, children), 0);
290 column.layout.width = Length::Fill(1);
291 column.layout.height = Length::Fill(1);
292 column
293}
294
295impl<Msg: 'static> Container<Msg> for Window<Msg> {
296 fn set_children(&mut self, children: Vec<Node<Msg>>) {
297 self.body[0] = body(children);
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303enum Mark {
304 Minimize,
305 Maximize,
306 Close,
307}
308
309impl Mark {
310 const ALL: [Self; 3] = [Self::Minimize, Self::Maximize, Self::Close];
311
312 fn event(self) -> WindowEvent {
313 match self {
314 Self::Minimize => WindowEvent::Minimize,
315 Self::Maximize => WindowEvent::ToggleMaximize,
316 Self::Close => WindowEvent::Close,
317 }
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323enum Part {
324 Title,
325 Mark(Mark),
326 Handle(WindowEdge),
327 Body,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332enum Grab {
333 Move { button: MouseButton, start: (i32, i32), last: (i32, i32) },
335 Resize { button: MouseButton, edge: WindowEdge, start: (i32, i32), last: (i32, i32) },
337 Mark(Mark),
339}
340
341#[derive(Debug, Default)]
342struct WindowMemory {
343 grab: Option<Grab>,
344 moved: bool,
346 title_press: Option<std::time::Duration>,
348}
349
350impl<Msg: 'static> Window<Msg> {
351 fn interactive(&self) -> bool {
352 self.on_event.is_some()
353 }
354
355 fn content(area: Rect) -> Rect {
358 Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(3), area.height.saturating_sub(2))
359 }
360
361 fn marks_x(area: Rect) -> i32 {
364 area.right() - 1 - i32::from(MARKS)
365 }
366
367 fn part_at(&self, area: Rect, x: i32, y: i32) -> Option<Part> {
374 if !area.contains(x, y) {
375 return None;
376 }
377 if !self.interactive() {
378 return Some(if y == area.y { Part::Title } else { Part::Body });
379 }
380 let (left, right) = (x == area.x, x == area.right() - 1);
381 let (top, bottom) = (y == area.y, y == area.bottom() - 1);
382 Some(match (left, right, top, bottom) {
383 (true, _, true, _) => Part::Handle(WindowEdge::TopLeft),
384 (_, true, true, _) => Part::Handle(WindowEdge::TopRight),
385 (_, _, true, _) => {
386 let marks = Self::marks_x(area);
387 if x >= marks {
388 let index = usize::try_from((x - marks) / i32::from(close_mark::WIDTH)).unwrap_or(0);
389 Part::Mark(Mark::ALL[index.min(2)])
390 } else {
391 Part::Title
392 }
393 }
394 (true, _, _, true) => Part::Handle(WindowEdge::BottomLeft),
395 (_, true, _, true) => Part::Handle(WindowEdge::BottomRight),
396 (true, _, _, _) => Part::Handle(WindowEdge::Left),
397 (_, true, _, _) => Part::Handle(WindowEdge::Right),
398 (_, _, _, true) => Part::Handle(WindowEdge::Bottom),
399 _ => Part::Body,
400 })
401 }
402
403 fn nearest_edge(area: Rect, x: i32, y: i32) -> WindowEdge {
407 let third = |offset: i32, length: u16| (offset * 3 / i32::from(length.max(1))).clamp(0, 2);
408 match (third(x - area.x, area.width), third(y - area.y, area.height)) {
409 (0, 0) => WindowEdge::TopLeft,
410 (1, 0) => WindowEdge::Top,
411 (2, 0) => WindowEdge::TopRight,
412 (0, 1) => WindowEdge::Left,
413 (2, 1) => WindowEdge::Right,
414 (0, 2) => WindowEdge::BottomLeft,
415 (1, 2) => WindowEdge::Bottom,
416 (2, 2) => WindowEdge::BottomRight,
417 _ => {
418 let sides = [
419 (x - area.x, WindowEdge::Left),
420 (area.right() - 1 - x, WindowEdge::Right),
421 (2 * (y - area.y), WindowEdge::Top),
422 (2 * (area.bottom() - 1 - y), WindowEdge::Bottom),
423 ];
424 sides.into_iter().min_by_key(|(distance, _)| *distance).map_or(WindowEdge::Right, |(_, edge)| edge)
425 }
426 }
427 }
428
429 fn send(&self, cx: &mut EventCx<'_, Msg>, event: WindowEvent) {
430 if let Some(message) = &self.on_event {
431 cx.emit(message(event));
432 }
433 }
434
435 fn press(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent, button: MouseButton) -> bool {
436 let area = cx.area();
437 let Some(part) = self.part_at(area, mouse.x, mouse.y) else {
438 return false;
439 };
440 if !self.focused {
441 self.send(cx, WindowEvent::Focus);
442 }
443 let at = (mouse.x, mouse.y);
444 let now = cx.now();
445 let memory = cx.memory::<WindowMemory>();
446 let grab = match (mouse.mods.alt, button, part) {
447 (true, MouseButton::Left, _) => Grab::Move { button, start: at, last: at },
448 (true, MouseButton::Right, _) => {
449 Grab::Resize { button, edge: Self::nearest_edge(area, mouse.x, mouse.y), start: at, last: at }
450 }
451 (_, MouseButton::Left, Part::Body) | (_, MouseButton::Right | MouseButton::Middle, _) => {
453 return part != Part::Body;
454 }
455 (_, MouseButton::Left, Part::Mark(mark)) => Grab::Mark(mark),
456 (_, MouseButton::Left, Part::Handle(edge)) => Grab::Resize { button, edge, start: at, last: at },
457 (_, MouseButton::Left, Part::Title) => {
458 if memory.title_press.is_some_and(|last| click::is_double(last, now)) {
459 memory.title_press = None;
460 memory.grab = None;
461 cx.capture_pointer();
462 self.send(cx, WindowEvent::ToggleMaximize);
463 return true;
464 }
465 memory.title_press = Some(now);
466 Grab::Move { button, start: at, last: at }
467 }
468 };
469 if !matches!(grab, Grab::Move { .. }) || part != Part::Title {
470 memory.title_press = None;
471 }
472 memory.grab = Some(grab);
473 memory.moved = false;
474 cx.capture_pointer();
475 true
476 }
477
478 fn drag(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent, button: MouseButton) -> bool {
479 let at = (mouse.x, mouse.y);
480 let memory = cx.memory::<WindowMemory>();
481 let drag = match memory.grab {
482 Some(Grab::Move { button: held, start, last }) if held == button => {
483 memory.grab = Some(Grab::Move { button, start, last: at });
484 let (dx, dy) = (at.0 - last.0, at.1 - last.1);
485 if (dx, dy) != (0, 0) {
486 memory.title_press = None;
487 }
488 ((dx, dy) != (0, 0)).then_some(WindowDrag {
489 step: WindowEvent::Move { dx, dy },
490 total_dx: at.0 - start.0,
491 total_dy: at.1 - start.1,
492 })
493 }
494 Some(Grab::Resize { button: held, edge, start, last }) if held == button => {
495 memory.grab = Some(Grab::Resize { button, edge, start, last: at });
496 let columns = edge.left() || edge.right();
497 let rows = edge.top() || edge.bottom();
498 let dx = if columns { at.0 - last.0 } else { 0 };
499 let dy = if rows { at.1 - last.1 } else { 0 };
500 ((dx, dy) != (0, 0)).then_some(WindowDrag {
501 step: WindowEvent::Resize { edge, dx, dy },
502 total_dx: if columns { at.0 - start.0 } else { 0 },
503 total_dy: if rows { at.1 - start.1 } else { 0 },
504 })
505 }
506 Some(Grab::Mark(_)) => None,
507 _ => return false,
508 };
509 if let Some(drag) = drag {
510 cx.memory::<WindowMemory>().moved = true;
511 match &self.on_drag {
512 Some(message) if self.interactive() => cx.emit(message(drag)),
513 _ => self.send(cx, drag.step),
514 }
515 }
516 true
517 }
518
519 fn release(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent) -> bool {
520 let area = cx.area();
521 let memory = cx.memory::<WindowMemory>();
522 let (Some(grab), moved) = (memory.grab.take(), memory.moved) else {
523 return false;
524 };
525 memory.moved = false;
526 match grab {
527 Grab::Mark(mark) if self.part_at(area, mouse.x, mouse.y) == Some(Part::Mark(mark)) => {
528 self.send(cx, mark.event());
529 }
530 Grab::Move { .. } | Grab::Resize { .. } if moved => self.send(cx, WindowEvent::Dropped),
531 _ => {}
532 }
533 true
534 }
535
536 fn paint_shadow(cx: &mut PaintCx<'_>, area: Rect) {
538 let depth = cx.env().depth();
539 if depth == ColorDepth::Ansi16 || cx.reduced_motion() {
540 return;
541 }
542 let style = cx.style("window-shadow", None, &[]);
543 let scrim = style.color("scrim").unwrap_or_else(|| cx.color("canvas"));
544 let strength = f32::from(style.cells("strength").unwrap_or(DEFAULT_SHADOW).min(100)) / 100.0;
545 let rects = [
546 Rect::new(area.right(), area.y + 1, 1, area.height.saturating_sub(1)),
547 Rect::new(area.x + 1, area.bottom(), area.width, 1),
548 ];
549 for rect in rects {
550 if depth == ColorDepth::TrueColor {
551 cx.tint(rect, scrim, strength);
552 } else {
553 let ground = cx.color("canvas").mix(scrim, strength);
555 cx.fill(rect, ground);
556 }
557 }
558 }
559
560 fn fit_title(&self, icon: Option<&str>, room: u16) -> (Option<String>, String, Option<String>) {
563 let lead = icon.map_or(0, |glyph| text::width(glyph).saturating_add(1));
564 let name = text::width(&self.title);
565 let icon = icon.filter(|glyph| text::width(glyph) <= room).map(str::to_owned);
566 let before = lead.saturating_add(name).saturating_add(TITLE_GAP);
567 if let Some(subtitle) = self.subtitle.as_deref().filter(|subtitle| !subtitle.is_empty()) {
568 let left = room.saturating_sub(before);
569 if before <= room && left >= MIN_SUBTITLE.min(text::width(subtitle)) {
570 return (icon, self.title.clone(), Some(text::truncate(subtitle, left).into_owned()));
571 }
572 }
573 (icon, text::truncate(&self.title, room.saturating_sub(lead)).into_owned(), None)
574 }
575
576 fn title_look(&self, cx: &mut PaintCx<'_>, states: &[State], ground: Rgb) -> (Rgb, CellStyle, CellStyle) {
581 let name = cx.style("window-title", None, states).text();
582 let subtitle = cx.style("window-subtitle", None, states).text();
583 if cx.env().depth() != ColorDepth::Ansi16 {
584 return (name.bg.unwrap_or(ground), CellStyle { bg: None, ..name }, CellStyle { bg: None, ..subtitle });
585 }
586 let strip = cx.color(if self.focused { "accent" } else { "muted" });
587 let ink = Some(cx.color("ink"));
588 (strip, CellStyle { bg: None, fg: ink, ..name }, CellStyle { bg: None, fg: ink, ..subtitle })
589 }
590
591 fn paint_title(&self, cx: &mut PaintCx<'_>, area: Rect, style: CellStyle, subtitle_style: CellStyle) {
592 let marks = if self.interactive() { MARKS } else { 0 };
593 let start = area.x + 1;
594 let corner = i32::from(self.interactive());
597 let room = clamp_u16(i32::from(area.width) - 2 - corner - i32::from(marks));
598 let icon = self.icon.as_ref().map(|icon| icon.resolve(cx.env().icons()).into_owned());
599 let (icon, name, subtitle) = self.fit_title(icon.as_deref(), room);
600 let text_style = style;
601 let mut x = start;
602 if let Some(icon) = icon {
603 let width = cx.text(x, area.y, &icon, text_style, room);
604 x += i32::from(width) + 1;
605 }
606 let limit = clamp_u16(i32::from(room) - (x - start));
607 let width = cx.text(x, area.y, &name, text_style, limit);
608 x += i32::from(width) + i32::from(TITLE_GAP);
609 if let Some(subtitle) = subtitle {
610 let limit = clamp_u16(i32::from(room) - (x - start));
611 cx.text(x, area.y, &subtitle, subtitle_style, limit);
612 }
613 if self.interactive() {
614 let restore = if self.maximized { "window-restore" } else { "window-maximize" };
615 let marks_x = Self::marks_x(area);
616 for (index, key) in ["window-minimize", restore, "close"].into_iter().enumerate() {
617 let offset = i32::try_from(index).unwrap_or(0) * i32::from(close_mark::WIDTH);
618 close_mark::paint_glyph(cx, marks_x + offset, area.y, self.focused, key);
619 }
620 }
621 }
622
623 fn ask_pointer_shapes(cx: &mut PaintCx<'_>, area: Rect) {
625 let (right, bottom) = (area.right() - 1, area.bottom() - 1);
626 let handles = [
627 (Rect::new(area.x, area.y, 1, area.height), WindowEdge::Left),
628 (Rect::new(right, area.y, 1, area.height), WindowEdge::Right),
629 (Rect::new(area.x, bottom, area.width, 1), WindowEdge::Bottom),
630 (Rect::new(area.x, area.y, 1, 1), WindowEdge::TopLeft),
631 (Rect::new(right, area.y, 1, 1), WindowEdge::TopRight),
632 (Rect::new(area.x, bottom, 1, 1), WindowEdge::BottomLeft),
633 (Rect::new(right, bottom, 1, 1), WindowEdge::BottomRight),
634 ];
635 for (rect, edge) in handles {
636 cx.pointer_shape(rect, edge.pointer_shape());
637 }
638 }
639
640 fn paint_handles(&self, cx: &mut PaintCx<'_>, area: Rect) {
643 if area.height < 2 || area.width < 2 {
644 return;
645 }
646 let hovered = cx.pointer().and_then(|(x, y)| match self.part_at(area, x, y) {
647 Some(Part::Handle(edge)) => Some(edge),
648 _ => None,
649 });
650 let dragged = match cx.memory::<WindowMemory>().grab {
651 Some(Grab::Resize { edge, .. }) => Some(edge),
652 _ => None,
653 };
654 let right = area.right() - 1;
655 let handles: [(Rect, SideTest); 5] = [
656 (Rect::new(area.x, area.y, 1, area.height), WindowEdge::left),
657 (Rect::new(right, area.y, 1, area.height), WindowEdge::right),
658 (Rect::new(area.x, area.bottom() - 1, area.width, 1), WindowEdge::bottom),
659 (Rect::new(area.x, area.y, 1, 1), WindowEdge::top),
660 (Rect::new(right, area.y, 1, 1), WindowEdge::top),
661 ];
662 for (rect, moves) in handles {
663 let state = if dragged.is_some_and(moves) {
664 State::Active
665 } else if hovered.is_some_and(moves) {
666 State::Hover
667 } else {
668 continue;
669 };
670 let style = cx.style("split-handle", None, &[state]).text();
671 if let Some(bg) = style.bg {
672 cx.fill(rect, bg);
673 }
674 }
675 }
676}
677
678impl<Msg: 'static> Widget<Msg> for Window<Msg> {
679 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
680 available
681 }
682
683 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
684 if area.is_empty() {
685 return;
686 }
687 let states: &[State] = if self.focused { &[State::Focus] } else { &[] };
688 if self.shadow {
689 Self::paint_shadow(cx, area);
690 }
691 cx.register_hit(area);
692 let grab = if self.interactive() {
693 cx.preview_presses();
694 let grab = cx.memory::<WindowMemory>().grab;
695 let shape = match grab {
698 Some(Grab::Resize { edge, .. }) => edge.pointer_shape(),
699 _ => PointerShape::Default,
700 };
701 cx.pointer_shape(area, shape);
702 grab
703 } else {
704 None
705 };
706 let surface = cx.style("window", None, states);
707 let ground = surface.text().bg.unwrap_or_else(|| cx.color("surface"));
708 let (strip, name, subtitle) = self.title_look(cx, states, ground);
709 cx.clear(area, ground);
710 cx.clear(area.row(0), strip);
711 if let Some(pillar) = surface.color("pillar").filter(|_| self.focused) {
712 for y in area.y..area.bottom() {
713 cx.pillar(area.x, y, pillar);
714 }
715 }
716 self.paint_title(cx, area, name, subtitle);
717 cx.paint_child(&self.body[0], Self::content(area));
718 if self.interactive() {
719 self.paint_handles(cx, area);
720 if grab.is_none() {
721 Self::ask_pointer_shapes(cx, area);
722 }
723 }
724 }
725
726 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
727 if !self.interactive() {
728 return false;
729 }
730 let Event::Mouse(mouse) = event else {
731 return false;
732 };
733 match mouse.kind {
734 MouseKind::Down(button) if cx.is_preview() => self.press(cx, *mouse, button),
737 MouseKind::Drag(button) => self.drag(cx, *mouse, button),
738 MouseKind::Up(_) => self.release(cx, *mouse),
739 _ => false,
740 }
741 }
742
743 fn children(&self) -> &[Node<Msg>] {
744 &self.body
745 }
746
747 fn children_mut(&mut self) -> &mut [Node<Msg>] {
748 &mut self.body
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::{Mark, Part, Window, WindowEdge};
755 use crate::geometry::Rect;
756
757 #[test]
758 fn part_at_names_every_side_and_corner_and_leaves_the_title_between_the_top_corners() {
759 let window: Window<()> = Window::new("notes").on_event(|_| ());
760 let area = Rect::new(0, 0, 20, 5);
761 let part = |x, y| window.part_at(area, x, y);
762 assert_eq!(part(0, 0), Some(Part::Handle(WindowEdge::TopLeft)));
763 assert_eq!(part(19, 0), Some(Part::Handle(WindowEdge::TopRight)));
764 assert_eq!(part(0, 4), Some(Part::Handle(WindowEdge::BottomLeft)));
765 assert_eq!(part(19, 4), Some(Part::Handle(WindowEdge::BottomRight)));
766 assert_eq!(part(0, 2), Some(Part::Handle(WindowEdge::Left)));
767 assert_eq!(part(19, 2), Some(Part::Handle(WindowEdge::Right)));
768 assert_eq!(part(7, 4), Some(Part::Handle(WindowEdge::Bottom)));
769 assert_eq!((part(1, 0), part(9, 0)), (Some(Part::Title), Some(Part::Title)));
770 assert_eq!(part(10, 0), Some(Part::Mark(Mark::Minimize)), "the marks end left of the right column");
771 assert_eq!(part(18, 0), Some(Part::Mark(Mark::Close)));
772 assert_eq!(part(7, 2), Some(Part::Body));
773 assert_eq!(part(20, 2), None);
774 let still: Window<()> = Window::new("still");
775 assert_eq!((still.part_at(area, 0, 2), still.part_at(area, 19, 0)), (Some(Part::Body), Some(Part::Title)));
776 }
777}