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)]
34pub enum WindowEvent {
35 Focus,
38 Move {
41 dx: i32,
43 dy: i32,
45 },
46 Resize {
52 edge: WindowEdge,
54 dx: i32,
56 dy: i32,
58 },
59 Minimize,
61 ToggleMaximize,
63 Close,
65 Dropped,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum WindowEdge {
74 Left,
76 Right,
78 Top,
80 Bottom,
82 TopLeft,
84 TopRight,
86 BottomLeft,
88 BottomRight,
90}
91
92impl WindowEdge {
93 #[must_use]
96 pub fn left(self) -> bool {
97 matches!(self, Self::Left | Self::TopLeft | Self::BottomLeft)
98 }
99
100 #[must_use]
102 pub fn right(self) -> bool {
103 matches!(self, Self::Right | Self::TopRight | Self::BottomRight)
104 }
105
106 #[must_use]
108 pub fn top(self) -> bool {
109 matches!(self, Self::Top | Self::TopLeft | Self::TopRight)
110 }
111
112 #[must_use]
114 pub fn bottom(self) -> bool {
115 matches!(self, Self::Bottom | Self::BottomLeft | Self::BottomRight)
116 }
117
118 fn pointer_shape(self) -> PointerShape {
120 match self {
121 Self::Left | Self::Right => PointerShape::EwResize,
122 Self::Top | Self::Bottom => PointerShape::NsResize,
123 Self::TopLeft | Self::BottomRight => PointerShape::NwseResize,
124 Self::TopRight | Self::BottomLeft => PointerShape::NeswResize,
125 }
126 }
127}
128
129type SideTest = fn(WindowEdge) -> bool;
131
132type EventMessage<Msg> = Box<dyn Fn(WindowEvent) -> Msg>;
134
135pub struct Window<Msg> {
174 title: String,
175 subtitle: Option<String>,
176 icon: Option<Glyph>,
177 focused: bool,
178 maximized: bool,
179 shadow: bool,
180 on_event: Option<EventMessage<Msg>>,
181 body: Vec<Node<Msg>>,
183}
184
185impl<Msg: 'static> Window<Msg> {
186 #[must_use]
188 pub fn new(title: impl Into<String>) -> Self {
189 Self {
190 title: title.into(),
191 subtitle: None,
192 icon: None,
193 focused: false,
194 maximized: false,
195 shadow: false,
196 on_event: None,
197 body: vec![body(Vec::new())],
198 }
199 }
200
201 #[must_use]
203 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
204 self.subtitle = Some(subtitle.into());
205 self
206 }
207
208 #[must_use]
210 pub fn icon(mut self, glyph: impl Into<Glyph>) -> Self {
211 self.icon = Some(glyph.into());
212 self
213 }
214
215 #[must_use]
218 pub fn focused(mut self, focused: bool) -> Self {
219 self.focused = focused;
220 self
221 }
222
223 #[must_use]
225 pub fn maximized(mut self, maximized: bool) -> Self {
226 self.maximized = maximized;
227 self
228 }
229
230 #[must_use]
234 pub fn shadow(mut self, shadow: bool) -> Self {
235 self.shadow = shadow;
236 self
237 }
238
239 #[must_use]
242 pub fn on_event(mut self, message: impl Fn(WindowEvent) -> Msg + 'static) -> Self {
243 self.on_event = Some(Box::new(message));
244 self
245 }
246}
247
248fn body<Msg: 'static>(children: Vec<Node<Msg>>) -> Node<Msg> {
249 let mut column = Node::new(Flex::new(Axis::Column, children), 0);
250 column.layout.width = Length::Fill(1);
251 column.layout.height = Length::Fill(1);
252 column
253}
254
255impl<Msg: 'static> Container<Msg> for Window<Msg> {
256 fn set_children(&mut self, children: Vec<Node<Msg>>) {
257 self.body[0] = body(children);
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263enum Mark {
264 Minimize,
265 Maximize,
266 Close,
267}
268
269impl Mark {
270 const ALL: [Self; 3] = [Self::Minimize, Self::Maximize, Self::Close];
271
272 fn event(self) -> WindowEvent {
273 match self {
274 Self::Minimize => WindowEvent::Minimize,
275 Self::Maximize => WindowEvent::ToggleMaximize,
276 Self::Close => WindowEvent::Close,
277 }
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum Part {
284 Title,
285 Mark(Mark),
286 Handle(WindowEdge),
287 Body,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292enum Grab {
293 Move { button: MouseButton, last: (i32, i32) },
295 Resize { button: MouseButton, edge: WindowEdge, last: (i32, i32) },
297 Mark(Mark),
299}
300
301#[derive(Debug, Default)]
302struct WindowMemory {
303 grab: Option<Grab>,
304 moved: bool,
306 title_press: Option<std::time::Duration>,
308}
309
310impl<Msg: 'static> Window<Msg> {
311 fn interactive(&self) -> bool {
312 self.on_event.is_some()
313 }
314
315 fn content(area: Rect) -> Rect {
318 Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(3), area.height.saturating_sub(2))
319 }
320
321 fn marks_x(area: Rect) -> i32 {
324 area.right() - 1 - i32::from(MARKS)
325 }
326
327 fn part_at(&self, area: Rect, x: i32, y: i32) -> Option<Part> {
334 if !area.contains(x, y) {
335 return None;
336 }
337 if !self.interactive() {
338 return Some(if y == area.y { Part::Title } else { Part::Body });
339 }
340 let (left, right) = (x == area.x, x == area.right() - 1);
341 let (top, bottom) = (y == area.y, y == area.bottom() - 1);
342 Some(match (left, right, top, bottom) {
343 (true, _, true, _) => Part::Handle(WindowEdge::TopLeft),
344 (_, true, true, _) => Part::Handle(WindowEdge::TopRight),
345 (_, _, true, _) => {
346 let marks = Self::marks_x(area);
347 if x >= marks {
348 let index = usize::try_from((x - marks) / i32::from(close_mark::WIDTH)).unwrap_or(0);
349 Part::Mark(Mark::ALL[index.min(2)])
350 } else {
351 Part::Title
352 }
353 }
354 (true, _, _, true) => Part::Handle(WindowEdge::BottomLeft),
355 (_, true, _, true) => Part::Handle(WindowEdge::BottomRight),
356 (true, _, _, _) => Part::Handle(WindowEdge::Left),
357 (_, true, _, _) => Part::Handle(WindowEdge::Right),
358 (_, _, _, true) => Part::Handle(WindowEdge::Bottom),
359 _ => Part::Body,
360 })
361 }
362
363 fn nearest_edge(area: Rect, x: i32, y: i32) -> WindowEdge {
367 let third = |offset: i32, length: u16| (offset * 3 / i32::from(length.max(1))).clamp(0, 2);
368 match (third(x - area.x, area.width), third(y - area.y, area.height)) {
369 (0, 0) => WindowEdge::TopLeft,
370 (1, 0) => WindowEdge::Top,
371 (2, 0) => WindowEdge::TopRight,
372 (0, 1) => WindowEdge::Left,
373 (2, 1) => WindowEdge::Right,
374 (0, 2) => WindowEdge::BottomLeft,
375 (1, 2) => WindowEdge::Bottom,
376 (2, 2) => WindowEdge::BottomRight,
377 _ => {
378 let sides = [
379 (x - area.x, WindowEdge::Left),
380 (area.right() - 1 - x, WindowEdge::Right),
381 (2 * (y - area.y), WindowEdge::Top),
382 (2 * (area.bottom() - 1 - y), WindowEdge::Bottom),
383 ];
384 sides.into_iter().min_by_key(|(distance, _)| *distance).map_or(WindowEdge::Right, |(_, edge)| edge)
385 }
386 }
387 }
388
389 fn send(&self, cx: &mut EventCx<'_, Msg>, event: WindowEvent) {
390 if let Some(message) = &self.on_event {
391 cx.emit(message(event));
392 }
393 }
394
395 fn press(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent, button: MouseButton) -> bool {
396 let area = cx.area();
397 let Some(part) = self.part_at(area, mouse.x, mouse.y) else {
398 return false;
399 };
400 if !self.focused {
401 self.send(cx, WindowEvent::Focus);
402 }
403 let at = (mouse.x, mouse.y);
404 let now = cx.now();
405 let memory = cx.memory::<WindowMemory>();
406 let grab = match (mouse.mods.alt, button, part) {
407 (true, MouseButton::Left, _) => Grab::Move { button, last: at },
408 (true, MouseButton::Right, _) => {
409 Grab::Resize { button, edge: Self::nearest_edge(area, mouse.x, mouse.y), last: at }
410 }
411 (_, MouseButton::Left, Part::Body) | (_, MouseButton::Right | MouseButton::Middle, _) => {
413 return part != Part::Body;
414 }
415 (_, MouseButton::Left, Part::Mark(mark)) => Grab::Mark(mark),
416 (_, MouseButton::Left, Part::Handle(edge)) => Grab::Resize { button, edge, last: at },
417 (_, MouseButton::Left, Part::Title) => {
418 if memory.title_press.is_some_and(|last| click::is_double(last, now)) {
419 memory.title_press = None;
420 memory.grab = None;
421 cx.capture_pointer();
422 self.send(cx, WindowEvent::ToggleMaximize);
423 return true;
424 }
425 memory.title_press = Some(now);
426 Grab::Move { button, last: at }
427 }
428 };
429 if !matches!(grab, Grab::Move { .. }) || part != Part::Title {
430 memory.title_press = None;
431 }
432 memory.grab = Some(grab);
433 memory.moved = false;
434 cx.capture_pointer();
435 true
436 }
437
438 fn drag(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent, button: MouseButton) -> bool {
439 let at = (mouse.x, mouse.y);
440 let memory = cx.memory::<WindowMemory>();
441 let event = match memory.grab {
442 Some(Grab::Move { button: held, last }) if held == button => {
443 memory.grab = Some(Grab::Move { button, last: at });
444 let (dx, dy) = (at.0 - last.0, at.1 - last.1);
445 if (dx, dy) != (0, 0) {
446 memory.title_press = None;
447 }
448 ((dx, dy) != (0, 0)).then_some(WindowEvent::Move { dx, dy })
449 }
450 Some(Grab::Resize { button: held, edge, last }) if held == button => {
451 memory.grab = Some(Grab::Resize { button, edge, last: at });
452 let dx = if edge.left() || edge.right() { at.0 - last.0 } else { 0 };
453 let dy = if edge.top() || edge.bottom() { at.1 - last.1 } else { 0 };
454 ((dx, dy) != (0, 0)).then_some(WindowEvent::Resize { edge, dx, dy })
455 }
456 Some(Grab::Mark(_)) => None,
457 _ => return false,
458 };
459 if let Some(event) = event {
460 cx.memory::<WindowMemory>().moved = true;
461 self.send(cx, event);
462 }
463 true
464 }
465
466 fn release(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent) -> bool {
467 let area = cx.area();
468 let memory = cx.memory::<WindowMemory>();
469 let (Some(grab), moved) = (memory.grab.take(), memory.moved) else {
470 return false;
471 };
472 memory.moved = false;
473 match grab {
474 Grab::Mark(mark) if self.part_at(area, mouse.x, mouse.y) == Some(Part::Mark(mark)) => {
475 self.send(cx, mark.event());
476 }
477 Grab::Move { .. } | Grab::Resize { .. } if moved => self.send(cx, WindowEvent::Dropped),
478 _ => {}
479 }
480 true
481 }
482
483 fn paint_shadow(cx: &mut PaintCx<'_>, area: Rect) {
485 let depth = cx.env().depth();
486 if depth == ColorDepth::Ansi16 || cx.reduced_motion() {
487 return;
488 }
489 let style = cx.style("window-shadow", None, &[]);
490 let scrim = style.color("scrim").unwrap_or_else(|| cx.color("canvas"));
491 let strength = f32::from(style.cells("strength").unwrap_or(DEFAULT_SHADOW).min(100)) / 100.0;
492 let rects = [
493 Rect::new(area.right(), area.y + 1, 1, area.height.saturating_sub(1)),
494 Rect::new(area.x + 1, area.bottom(), area.width, 1),
495 ];
496 for rect in rects {
497 if depth == ColorDepth::TrueColor {
498 cx.tint(rect, scrim, strength);
499 } else {
500 let ground = cx.color("canvas").mix(scrim, strength);
502 cx.fill(rect, ground);
503 }
504 }
505 }
506
507 fn fit_title(&self, icon: Option<&str>, room: u16) -> (Option<String>, String, Option<String>) {
510 let lead = icon.map_or(0, |glyph| text::width(glyph).saturating_add(1));
511 let name = text::width(&self.title);
512 let icon = icon.filter(|glyph| text::width(glyph) <= room).map(str::to_owned);
513 let before = lead.saturating_add(name).saturating_add(TITLE_GAP);
514 if let Some(subtitle) = self.subtitle.as_deref().filter(|subtitle| !subtitle.is_empty()) {
515 let left = room.saturating_sub(before);
516 if before <= room && left >= MIN_SUBTITLE.min(text::width(subtitle)) {
517 return (icon, self.title.clone(), Some(text::truncate(subtitle, left).into_owned()));
518 }
519 }
520 (icon, text::truncate(&self.title, room.saturating_sub(lead)).into_owned(), None)
521 }
522
523 fn title_look(&self, cx: &mut PaintCx<'_>, states: &[State], ground: Rgb) -> (Rgb, CellStyle, CellStyle) {
528 let name = cx.style("window-title", None, states).text();
529 let subtitle = cx.style("window-subtitle", None, states).text();
530 if cx.env().depth() != ColorDepth::Ansi16 {
531 return (name.bg.unwrap_or(ground), CellStyle { bg: None, ..name }, CellStyle { bg: None, ..subtitle });
532 }
533 let strip = cx.color(if self.focused { "accent" } else { "muted" });
534 let ink = Some(cx.color("ink"));
535 (strip, CellStyle { bg: None, fg: ink, ..name }, CellStyle { bg: None, fg: ink, ..subtitle })
536 }
537
538 fn paint_title(&self, cx: &mut PaintCx<'_>, area: Rect, style: CellStyle, subtitle_style: CellStyle) {
539 let marks = if self.interactive() { MARKS } else { 0 };
540 let start = area.x + 1;
541 let corner = i32::from(self.interactive());
544 let room = clamp_u16(i32::from(area.width) - 2 - corner - i32::from(marks));
545 let icon = self.icon.as_ref().map(|icon| icon.resolve(cx.env().icons()).into_owned());
546 let (icon, name, subtitle) = self.fit_title(icon.as_deref(), room);
547 let text_style = style;
548 let mut x = start;
549 if let Some(icon) = icon {
550 let width = cx.text(x, area.y, &icon, text_style, room);
551 x += i32::from(width) + 1;
552 }
553 let limit = clamp_u16(i32::from(room) - (x - start));
554 let width = cx.text(x, area.y, &name, text_style, limit);
555 x += i32::from(width) + i32::from(TITLE_GAP);
556 if let Some(subtitle) = subtitle {
557 let limit = clamp_u16(i32::from(room) - (x - start));
558 cx.text(x, area.y, &subtitle, subtitle_style, limit);
559 }
560 if self.interactive() {
561 let restore = if self.maximized { "window-restore" } else { "window-maximize" };
562 let marks_x = Self::marks_x(area);
563 for (index, key) in ["window-minimize", restore, "close"].into_iter().enumerate() {
564 let offset = i32::try_from(index).unwrap_or(0) * i32::from(close_mark::WIDTH);
565 close_mark::paint_glyph(cx, marks_x + offset, area.y, self.focused, key);
566 }
567 }
568 }
569
570 fn ask_pointer_shapes(cx: &mut PaintCx<'_>, area: Rect) {
572 let (right, bottom) = (area.right() - 1, area.bottom() - 1);
573 let handles = [
574 (Rect::new(area.x, area.y, 1, area.height), WindowEdge::Left),
575 (Rect::new(right, area.y, 1, area.height), WindowEdge::Right),
576 (Rect::new(area.x, bottom, area.width, 1), WindowEdge::Bottom),
577 (Rect::new(area.x, area.y, 1, 1), WindowEdge::TopLeft),
578 (Rect::new(right, area.y, 1, 1), WindowEdge::TopRight),
579 (Rect::new(area.x, bottom, 1, 1), WindowEdge::BottomLeft),
580 (Rect::new(right, bottom, 1, 1), WindowEdge::BottomRight),
581 ];
582 for (rect, edge) in handles {
583 cx.pointer_shape(rect, edge.pointer_shape());
584 }
585 }
586
587 fn paint_handles(&self, cx: &mut PaintCx<'_>, area: Rect) {
590 if area.height < 2 || area.width < 2 {
591 return;
592 }
593 let hovered = cx.pointer().and_then(|(x, y)| match self.part_at(area, x, y) {
594 Some(Part::Handle(edge)) => Some(edge),
595 _ => None,
596 });
597 let dragged = match cx.memory::<WindowMemory>().grab {
598 Some(Grab::Resize { edge, .. }) => Some(edge),
599 _ => None,
600 };
601 let right = area.right() - 1;
602 let handles: [(Rect, SideTest); 5] = [
603 (Rect::new(area.x, area.y, 1, area.height), WindowEdge::left),
604 (Rect::new(right, area.y, 1, area.height), WindowEdge::right),
605 (Rect::new(area.x, area.bottom() - 1, area.width, 1), WindowEdge::bottom),
606 (Rect::new(area.x, area.y, 1, 1), WindowEdge::top),
607 (Rect::new(right, area.y, 1, 1), WindowEdge::top),
608 ];
609 for (rect, moves) in handles {
610 let state = if dragged.is_some_and(moves) {
611 State::Active
612 } else if hovered.is_some_and(moves) {
613 State::Hover
614 } else {
615 continue;
616 };
617 let style = cx.style("split-handle", None, &[state]).text();
618 if let Some(bg) = style.bg {
619 cx.fill(rect, bg);
620 }
621 }
622 }
623}
624
625impl<Msg: 'static> Widget<Msg> for Window<Msg> {
626 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
627 available
628 }
629
630 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
631 if area.is_empty() {
632 return;
633 }
634 let states: &[State] = if self.focused { &[State::Focus] } else { &[] };
635 if self.shadow {
636 Self::paint_shadow(cx, area);
637 }
638 cx.register_hit(area);
639 let grab = if self.interactive() {
640 cx.preview_presses();
641 let grab = cx.memory::<WindowMemory>().grab;
642 let shape = match grab {
645 Some(Grab::Resize { edge, .. }) => edge.pointer_shape(),
646 _ => PointerShape::Default,
647 };
648 cx.pointer_shape(area, shape);
649 grab
650 } else {
651 None
652 };
653 let surface = cx.style("window", None, states);
654 let ground = surface.text().bg.unwrap_or_else(|| cx.color("surface"));
655 let (strip, name, subtitle) = self.title_look(cx, states, ground);
656 cx.clear(area, ground);
657 cx.clear(area.row(0), strip);
658 if let Some(pillar) = surface.color("pillar").filter(|_| self.focused) {
659 for y in area.y..area.bottom() {
660 cx.pillar(area.x, y, pillar);
661 }
662 }
663 self.paint_title(cx, area, name, subtitle);
664 cx.paint_child(&self.body[0], Self::content(area));
665 if self.interactive() {
666 self.paint_handles(cx, area);
667 if grab.is_none() {
668 Self::ask_pointer_shapes(cx, area);
669 }
670 }
671 }
672
673 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
674 if !self.interactive() {
675 return false;
676 }
677 let Event::Mouse(mouse) = event else {
678 return false;
679 };
680 match mouse.kind {
681 MouseKind::Down(button) if cx.is_preview() => self.press(cx, *mouse, button),
684 MouseKind::Drag(button) => self.drag(cx, *mouse, button),
685 MouseKind::Up(_) => self.release(cx, *mouse),
686 _ => false,
687 }
688 }
689
690 fn children(&self) -> &[Node<Msg>] {
691 &self.body
692 }
693
694 fn children_mut(&mut self) -> &mut [Node<Msg>] {
695 &mut self.body
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::{Mark, Part, Window, WindowEdge};
702 use crate::geometry::Rect;
703
704 #[test]
705 fn part_at_names_every_side_and_corner_and_leaves_the_title_between_the_top_corners() {
706 let window: Window<()> = Window::new("notes").on_event(|_| ());
707 let area = Rect::new(0, 0, 20, 5);
708 let part = |x, y| window.part_at(area, x, y);
709 assert_eq!(part(0, 0), Some(Part::Handle(WindowEdge::TopLeft)));
710 assert_eq!(part(19, 0), Some(Part::Handle(WindowEdge::TopRight)));
711 assert_eq!(part(0, 4), Some(Part::Handle(WindowEdge::BottomLeft)));
712 assert_eq!(part(19, 4), Some(Part::Handle(WindowEdge::BottomRight)));
713 assert_eq!(part(0, 2), Some(Part::Handle(WindowEdge::Left)));
714 assert_eq!(part(19, 2), Some(Part::Handle(WindowEdge::Right)));
715 assert_eq!(part(7, 4), Some(Part::Handle(WindowEdge::Bottom)));
716 assert_eq!((part(1, 0), part(9, 0)), (Some(Part::Title), Some(Part::Title)));
717 assert_eq!(part(10, 0), Some(Part::Mark(Mark::Minimize)), "the marks end left of the right column");
718 assert_eq!(part(18, 0), Some(Part::Mark(Mark::Close)));
719 assert_eq!(part(7, 2), Some(Part::Body));
720 assert_eq!(part(20, 2), None);
721 let still: Window<()> = Window::new("still");
722 assert_eq!((still.part_at(area, 0, 2), still.part_at(area, 19, 0)), (Some(Part::Body), Some(Part::Title)));
723 }
724}