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::runtime::MULTI_PRESS;
9use crate::style::CellStyle;
10use crate::text;
11use crate::theme::State;
12use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget};
13
14use super::close_mark;
15
16const MARKS: u16 = close_mark::WIDTH * 3;
18
19const TITLE_GAP: u16 = 2;
21
22const MIN_SUBTITLE: u16 = 4;
25
26const DEFAULT_SHADOW: u16 = 45;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WindowEvent {
36 Focus,
39 Move {
42 dx: i32,
44 dy: i32,
46 },
47 Resize {
50 edge: WindowEdge,
52 dx: i32,
54 dy: i32,
56 },
57 Minimize,
59 ToggleMaximize,
61 Close,
63 Dropped,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum WindowEdge {
72 Left,
74 Right,
76 Top,
78 Bottom,
80 TopLeft,
82 TopRight,
84 BottomLeft,
86 BottomRight,
88}
89
90impl WindowEdge {
91 #[must_use]
94 pub fn left(self) -> bool {
95 matches!(self, Self::Left | Self::TopLeft | Self::BottomLeft)
96 }
97
98 #[must_use]
100 pub fn right(self) -> bool {
101 matches!(self, Self::Right | Self::TopRight | Self::BottomRight)
102 }
103
104 #[must_use]
106 pub fn top(self) -> bool {
107 matches!(self, Self::Top | Self::TopLeft | Self::TopRight)
108 }
109
110 #[must_use]
112 pub fn bottom(self) -> bool {
113 matches!(self, Self::Bottom | Self::BottomLeft | Self::BottomRight)
114 }
115}
116
117type EventMessage<Msg> = Box<dyn Fn(WindowEvent) -> Msg>;
119
120pub struct Window<Msg> {
154 title: String,
155 subtitle: Option<String>,
156 icon: Option<Glyph>,
157 focused: bool,
158 maximized: bool,
159 shadow: bool,
160 on_event: Option<EventMessage<Msg>>,
161 body: Vec<Node<Msg>>,
163}
164
165impl<Msg: 'static> Window<Msg> {
166 #[must_use]
168 pub fn new(title: impl Into<String>) -> Self {
169 Self {
170 title: title.into(),
171 subtitle: None,
172 icon: None,
173 focused: false,
174 maximized: false,
175 shadow: false,
176 on_event: None,
177 body: vec![body(Vec::new())],
178 }
179 }
180
181 #[must_use]
183 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
184 self.subtitle = Some(subtitle.into());
185 self
186 }
187
188 #[must_use]
190 pub fn icon(mut self, glyph: impl Into<Glyph>) -> Self {
191 self.icon = Some(glyph.into());
192 self
193 }
194
195 #[must_use]
198 pub fn focused(mut self, focused: bool) -> Self {
199 self.focused = focused;
200 self
201 }
202
203 #[must_use]
205 pub fn maximized(mut self, maximized: bool) -> Self {
206 self.maximized = maximized;
207 self
208 }
209
210 #[must_use]
214 pub fn shadow(mut self, shadow: bool) -> Self {
215 self.shadow = shadow;
216 self
217 }
218
219 #[must_use]
222 pub fn on_event(mut self, message: impl Fn(WindowEvent) -> Msg + 'static) -> Self {
223 self.on_event = Some(Box::new(message));
224 self
225 }
226}
227
228fn body<Msg: 'static>(children: Vec<Node<Msg>>) -> Node<Msg> {
229 let mut column = Node::new(Flex::new(Axis::Column, children), 0);
230 column.layout.width = Length::Fill(1);
231 column.layout.height = Length::Fill(1);
232 column
233}
234
235impl<Msg: 'static> Container<Msg> for Window<Msg> {
236 fn set_children(&mut self, children: Vec<Node<Msg>>) {
237 self.body[0] = body(children);
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum Mark {
244 Minimize,
245 Maximize,
246 Close,
247}
248
249impl Mark {
250 const ALL: [Self; 3] = [Self::Minimize, Self::Maximize, Self::Close];
251
252 fn event(self) -> WindowEvent {
253 match self {
254 Self::Minimize => WindowEvent::Minimize,
255 Self::Maximize => WindowEvent::ToggleMaximize,
256 Self::Close => WindowEvent::Close,
257 }
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263enum Part {
264 Title,
265 Mark(Mark),
266 Handle(WindowEdge),
267 Body,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272enum Grab {
273 Move { button: MouseButton, last: (i32, i32) },
275 Resize { button: MouseButton, edge: WindowEdge, last: (i32, i32) },
277 Mark(Mark),
279}
280
281#[derive(Debug, Default)]
282struct WindowMemory {
283 grab: Option<Grab>,
284 moved: bool,
286 title_press: Option<std::time::Duration>,
288}
289
290impl<Msg: 'static> Window<Msg> {
291 fn interactive(&self) -> bool {
292 self.on_event.is_some()
293 }
294
295 fn content(area: Rect) -> Rect {
298 Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(3), area.height.saturating_sub(2))
299 }
300
301 fn part_at(&self, area: Rect, x: i32, y: i32) -> Option<Part> {
303 if !area.contains(x, y) {
304 return None;
305 }
306 let interactive = self.interactive();
307 if y == area.y {
308 let marks = area.right() - i32::from(MARKS);
309 if interactive && x >= marks {
310 let index = usize::try_from((x - marks) / i32::from(close_mark::WIDTH)).unwrap_or(0);
311 return Some(Part::Mark(Mark::ALL[index.min(2)]));
312 }
313 return Some(Part::Title);
314 }
315 let (right, bottom) = (x == area.right() - 1, y == area.bottom() - 1);
316 Some(match (interactive, right, bottom) {
317 (true, true, true) => Part::Handle(WindowEdge::BottomRight),
318 (true, true, false) => Part::Handle(WindowEdge::Right),
319 (true, false, true) if x > area.x => Part::Handle(WindowEdge::Bottom),
320 _ => Part::Body,
321 })
322 }
323
324 fn nearest_edge(area: Rect, x: i32, y: i32) -> WindowEdge {
328 let third = |offset: i32, length: u16| (offset * 3 / i32::from(length.max(1))).clamp(0, 2);
329 match (third(x - area.x, area.width), third(y - area.y, area.height)) {
330 (0, 0) => WindowEdge::TopLeft,
331 (1, 0) => WindowEdge::Top,
332 (2, 0) => WindowEdge::TopRight,
333 (0, 1) => WindowEdge::Left,
334 (2, 1) => WindowEdge::Right,
335 (0, 2) => WindowEdge::BottomLeft,
336 (1, 2) => WindowEdge::Bottom,
337 (2, 2) => WindowEdge::BottomRight,
338 _ => {
339 let sides = [
340 (x - area.x, WindowEdge::Left),
341 (area.right() - 1 - x, WindowEdge::Right),
342 (2 * (y - area.y), WindowEdge::Top),
343 (2 * (area.bottom() - 1 - y), WindowEdge::Bottom),
344 ];
345 sides.into_iter().min_by_key(|(distance, _)| *distance).map_or(WindowEdge::Right, |(_, edge)| edge)
346 }
347 }
348 }
349
350 fn send(&self, cx: &mut EventCx<'_, Msg>, event: WindowEvent) {
351 if let Some(message) = &self.on_event {
352 cx.emit(message(event));
353 }
354 }
355
356 fn press(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent, button: MouseButton) -> bool {
357 let area = cx.area();
358 let Some(part) = self.part_at(area, mouse.x, mouse.y) else {
359 return false;
360 };
361 if !self.focused {
362 self.send(cx, WindowEvent::Focus);
363 }
364 let at = (mouse.x, mouse.y);
365 let now = cx.now();
366 let memory = cx.memory::<WindowMemory>();
367 let grab = match (mouse.mods.alt, button, part) {
368 (true, MouseButton::Left, _) => Grab::Move { button, last: at },
369 (true, MouseButton::Right, _) => {
370 Grab::Resize { button, edge: Self::nearest_edge(area, mouse.x, mouse.y), last: at }
371 }
372 (_, MouseButton::Left, Part::Body) | (_, MouseButton::Right | MouseButton::Middle, _) => {
374 return part != Part::Body;
375 }
376 (_, MouseButton::Left, Part::Mark(mark)) => Grab::Mark(mark),
377 (_, MouseButton::Left, Part::Handle(edge)) => Grab::Resize { button, edge, last: at },
378 (_, MouseButton::Left, Part::Title) => {
379 if memory.title_press.is_some_and(|last| now.saturating_sub(last) < MULTI_PRESS) {
380 memory.title_press = None;
381 memory.grab = None;
382 cx.capture_pointer();
383 self.send(cx, WindowEvent::ToggleMaximize);
384 return true;
385 }
386 memory.title_press = Some(now);
387 Grab::Move { button, last: at }
388 }
389 };
390 if !matches!(grab, Grab::Move { .. }) || part != Part::Title {
391 memory.title_press = None;
392 }
393 memory.grab = Some(grab);
394 memory.moved = false;
395 cx.capture_pointer();
396 true
397 }
398
399 fn drag(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent, button: MouseButton) -> bool {
400 let at = (mouse.x, mouse.y);
401 let memory = cx.memory::<WindowMemory>();
402 let event = match memory.grab {
403 Some(Grab::Move { button: held, last }) if held == button => {
404 memory.grab = Some(Grab::Move { button, last: at });
405 let (dx, dy) = (at.0 - last.0, at.1 - last.1);
406 if (dx, dy) != (0, 0) {
407 memory.title_press = None;
408 }
409 ((dx, dy) != (0, 0)).then_some(WindowEvent::Move { dx, dy })
410 }
411 Some(Grab::Resize { button: held, edge, last }) if held == button => {
412 memory.grab = Some(Grab::Resize { button, edge, last: at });
413 let dx = if edge.left() || edge.right() { at.0 - last.0 } else { 0 };
414 let dy = if edge.top() || edge.bottom() { at.1 - last.1 } else { 0 };
415 ((dx, dy) != (0, 0)).then_some(WindowEvent::Resize { edge, dx, dy })
416 }
417 Some(Grab::Mark(_)) => None,
418 _ => return false,
419 };
420 if let Some(event) = event {
421 cx.memory::<WindowMemory>().moved = true;
422 self.send(cx, event);
423 }
424 true
425 }
426
427 fn release(&self, cx: &mut EventCx<'_, Msg>, mouse: MouseEvent) -> bool {
428 let area = cx.area();
429 let memory = cx.memory::<WindowMemory>();
430 let (Some(grab), moved) = (memory.grab.take(), memory.moved) else {
431 return false;
432 };
433 memory.moved = false;
434 match grab {
435 Grab::Mark(mark) if self.part_at(area, mouse.x, mouse.y) == Some(Part::Mark(mark)) => {
436 self.send(cx, mark.event());
437 }
438 Grab::Move { .. } | Grab::Resize { .. } if moved => self.send(cx, WindowEvent::Dropped),
439 _ => {}
440 }
441 true
442 }
443
444 fn paint_shadow(cx: &mut PaintCx<'_>, area: Rect) {
446 let depth = cx.env().depth();
447 if depth == ColorDepth::Ansi16 || cx.reduced_motion() {
448 return;
449 }
450 let style = cx.style("window-shadow", None, &[]);
451 let scrim = style.color("scrim").unwrap_or_else(|| cx.color("canvas"));
452 let strength = f32::from(style.cells("strength").unwrap_or(DEFAULT_SHADOW).min(100)) / 100.0;
453 let rects = [
454 Rect::new(area.right(), area.y + 1, 1, area.height.saturating_sub(1)),
455 Rect::new(area.x + 1, area.bottom(), area.width, 1),
456 ];
457 for rect in rects {
458 if depth == ColorDepth::TrueColor {
459 cx.tint(rect, scrim, strength);
460 } else {
461 let ground = cx.color("canvas").mix(scrim, strength);
463 cx.fill(rect, ground);
464 }
465 }
466 }
467
468 fn fit_title(&self, icon: Option<&str>, room: u16) -> (Option<String>, String, Option<String>) {
471 let lead = icon.map_or(0, |glyph| text::width(glyph).saturating_add(1));
472 let name = text::width(&self.title);
473 let icon = icon.filter(|glyph| text::width(glyph) <= room).map(str::to_owned);
474 let before = lead.saturating_add(name).saturating_add(TITLE_GAP);
475 if let Some(subtitle) = self.subtitle.as_deref().filter(|subtitle| !subtitle.is_empty()) {
476 let left = room.saturating_sub(before);
477 if before <= room && left >= MIN_SUBTITLE.min(text::width(subtitle)) {
478 return (icon, self.title.clone(), Some(text::truncate(subtitle, left).into_owned()));
479 }
480 }
481 (icon, text::truncate(&self.title, room.saturating_sub(lead)).into_owned(), None)
482 }
483
484 fn title_look(&self, cx: &mut PaintCx<'_>, states: &[State], ground: Rgb) -> (Rgb, CellStyle, CellStyle) {
488 let name = cx.style("window-title", None, states).text();
489 let subtitle = cx.style("window-subtitle", None, states).text();
490 if cx.env().depth() != ColorDepth::Ansi16 {
491 return (name.bg.unwrap_or(ground), CellStyle { bg: None, ..name }, CellStyle { bg: None, ..subtitle });
492 }
493 let strip = cx.color(if self.focused { "accent" } else { "muted" });
494 let ink = Some(cx.color("ink"));
495 (strip, CellStyle { bg: None, fg: ink, ..name }, CellStyle { bg: None, fg: ink, ..subtitle })
496 }
497
498 fn paint_title(&self, cx: &mut PaintCx<'_>, area: Rect, style: CellStyle, subtitle_style: CellStyle) {
499 let marks = if self.interactive() { MARKS } else { 0 };
500 let start = area.x + 1;
501 let room = clamp_u16(i32::from(area.width) - 2 - i32::from(marks));
502 let icon = self.icon.as_ref().map(|icon| icon.resolve(cx.env().icons()).into_owned());
503 let (icon, name, subtitle) = self.fit_title(icon.as_deref(), room);
504 let text_style = style;
505 let mut x = start;
506 if let Some(icon) = icon {
507 let width = cx.text(x, area.y, &icon, text_style, room);
508 x += i32::from(width) + 1;
509 }
510 let limit = clamp_u16(i32::from(room) - (x - start));
511 let width = cx.text(x, area.y, &name, text_style, limit);
512 x += i32::from(width) + i32::from(TITLE_GAP);
513 if let Some(subtitle) = subtitle {
514 let limit = clamp_u16(i32::from(room) - (x - start));
515 cx.text(x, area.y, &subtitle, subtitle_style, limit);
516 }
517 if self.interactive() {
518 let restore = if self.maximized { "window-restore" } else { "window-maximize" };
519 let marks_x = area.right() - i32::from(MARKS);
520 for (index, key) in ["window-minimize", restore, "close"].into_iter().enumerate() {
521 let offset = i32::try_from(index).unwrap_or(0) * i32::from(close_mark::WIDTH);
522 close_mark::paint_glyph(cx, marks_x + offset, area.y, self.focused, key);
523 }
524 }
525 }
526
527 fn paint_handles(&self, cx: &mut PaintCx<'_>, area: Rect) {
529 if area.height < 2 || area.width < 2 {
530 return;
531 }
532 let hovered = cx.pointer().and_then(|(x, y)| match self.part_at(area, x, y) {
533 Some(Part::Handle(edge)) => Some(edge),
534 _ => None,
535 });
536 let dragged = match cx.memory::<WindowMemory>().grab {
537 Some(Grab::Resize { edge, .. }) => Some(edge),
538 _ => None,
539 };
540 let handles = [
541 (Rect::new(area.right() - 1, area.y + 1, 1, area.height - 1), WindowEdge::right as fn(WindowEdge) -> bool),
542 (Rect::new(area.x + 1, area.bottom() - 1, area.width - 1, 1), WindowEdge::bottom),
543 ];
544 for (rect, moves) in handles {
545 let state = if dragged.is_some_and(moves) {
546 State::Active
547 } else if hovered.is_some_and(moves) {
548 State::Hover
549 } else {
550 continue;
551 };
552 let style = cx.style("split-handle", None, &[state]).text();
553 if let Some(bg) = style.bg {
554 cx.fill(rect, bg);
555 }
556 }
557 }
558}
559
560impl<Msg: 'static> Widget<Msg> for Window<Msg> {
561 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
562 available
563 }
564
565 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
566 if area.is_empty() {
567 return;
568 }
569 let states: &[State] = if self.focused { &[State::Focus] } else { &[] };
570 if self.shadow {
571 Self::paint_shadow(cx, area);
572 }
573 cx.register_hit(area);
574 if self.interactive() {
575 cx.preview_presses();
576 }
577 let surface = cx.style("window", None, states);
578 let ground = surface.text().bg.unwrap_or_else(|| cx.color("surface"));
579 let (strip, name, subtitle) = self.title_look(cx, states, ground);
580 cx.clear(area, ground);
581 cx.clear(area.row(0), strip);
582 if let Some(pillar) = surface.color("pillar").filter(|_| self.focused) {
583 for y in area.y..area.bottom() {
584 cx.pillar(area.x, y, pillar);
585 }
586 }
587 self.paint_title(cx, area, name, subtitle);
588 cx.paint_child(&self.body[0], Self::content(area));
589 if self.interactive() {
590 self.paint_handles(cx, area);
591 }
592 }
593
594 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
595 if !self.interactive() {
596 return false;
597 }
598 let Event::Mouse(mouse) = event else {
599 return false;
600 };
601 match mouse.kind {
602 MouseKind::Down(button) if cx.is_preview() => self.press(cx, *mouse, button),
605 MouseKind::Drag(button) => self.drag(cx, *mouse, button),
606 MouseKind::Up(_) => self.release(cx, *mouse),
607 _ => false,
608 }
609 }
610
611 fn children(&self) -> &[Node<Msg>] {
612 &self.body
613 }
614
615 fn children_mut(&mut self) -> &mut [Node<Msg>] {
616 &mut self.body
617 }
618}