1use iced::alignment;
2use iced::mouse;
3use iced::widget::{Space, button, column, container, mouse_area, row, scrollable, stack, text};
4use iced::{
5 Background, Border, Color, Element, Length, Shadow, Subscription, Task, Vector, application,
6 window,
7};
8
9use iced_window_chrome::{
10 ChromeSettings, Event, MacosTitlebarSeparatorStyle, WindowCornerPreference, WindowsBackdrop,
11 current_windows_capabilities,
12};
13
14const TITLEBAR_HEIGHT: f32 = 62.0;
15const TITLEBAR_HEIGHT_F64: f64 = 62.0;
16const MACOS_TRAFFIC_LIGHT_OFFSET: f64 = -15.0;
17const RESIZE_GUTTER: f32 = 8.0;
18const MIN_WINDOW_WIDTH: f32 = 760.0;
19const MIN_WINDOW_HEIGHT: f32 = 520.0;
20
21fn main() -> iced::Result {
22 application(CustomTitlebarDemo::boot, update, view)
23 .title(title)
24 .window(window_settings())
25 .subscription(subscription)
26 .run()
27}
28
29fn window_settings() -> window::Settings {
30 let mut settings = window::Settings {
31 size: iced::Size::new(1040.0, 760.0),
32 min_size: Some(iced::Size::new(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)),
33 ..window::Settings::default()
34 };
35
36 if cfg!(target_os = "windows") {
37 settings.decorations = false;
38 }
39
40 settings
41}
42
43fn title(_: &CustomTitlebarDemo) -> String {
44 String::from("custom-titlebar")
45}
46
47#[derive(Debug, Clone)]
48enum Message {
49 Chrome(Event),
50 TrackWindow(Option<window::Id>),
51 ResizeHover(Option<window::Direction>),
52 StartWindowDrag,
53 Resize(window::Direction),
54 ToggleMaximize,
55 Minimize,
56 Close,
57}
58
59#[derive(Debug, Clone)]
60struct CustomTitlebarDemo {
61 chrome: ChromeSettings,
62 platform: PlatformFlavor,
63 window_id: Option<window::Id>,
64}
65
66impl CustomTitlebarDemo {
67 fn boot() -> (Self, Task<Message>) {
68 let platform = PlatformFlavor::detect();
69 let chrome = platform.chrome_settings();
70
71 let state = Self {
72 chrome: chrome.clone(),
73 platform,
74 window_id: None,
75 };
76
77 (
78 state,
79 Task::batch([
80 iced_window_chrome::apply_to_latest(chrome),
81 window::latest().map(Message::TrackWindow),
82 ]),
83 )
84 }
85}
86
87fn update(state: &mut CustomTitlebarDemo, message: Message) -> Task<Message> {
88 match message {
89 Message::Chrome(event) => iced_window_chrome::handle(event),
90 Message::TrackWindow(id) => {
91 state.window_id = id;
92 Task::none()
93 }
94 Message::ResizeHover(direction) => state
95 .window_id
96 .filter(|_| state.platform.uses_custom_resize_handles())
97 .map(|id| set_resize_hover_cursor(id, direction))
98 .unwrap_or_else(Task::none),
99 Message::StartWindowDrag => state
100 .window_id
101 .filter(|_| state.platform.can_drag_titlebar())
102 .map(window::drag)
103 .unwrap_or_else(Task::none),
104 Message::Resize(direction) => state
105 .window_id
106 .filter(|_| state.platform.uses_custom_resize_handles())
107 .map(|id| window::drag_resize(id, direction))
108 .unwrap_or_else(Task::none),
109 Message::ToggleMaximize => state
110 .window_id
111 .filter(|_| state.platform.can_drag_titlebar())
112 .map(window::toggle_maximize)
113 .unwrap_or_else(Task::none),
114 Message::Minimize => state
115 .window_id
116 .filter(|_| state.platform.shows_custom_caption_buttons())
117 .map(|id| window::minimize(id, true))
118 .unwrap_or_else(Task::none),
119 Message::Close => state
120 .window_id
121 .filter(|_| state.platform.shows_custom_caption_buttons())
122 .map(window::close)
123 .unwrap_or_else(Task::none),
124 }
125}
126
127fn subscription(state: &CustomTitlebarDemo) -> Subscription<Message> {
128 Subscription::batch([
129 iced_window_chrome::subscription(state.chrome.clone()).map(Message::Chrome),
130 window::open_events().map(|id| Message::TrackWindow(Some(id))),
131 ])
132}
133
134fn view(state: &CustomTitlebarDemo) -> Element<'_, Message> {
135 let shell: Element<'_, Message> = if state.platform.uses_custom_resize_handles() {
136 custom_resize_shell(state)
137 } else {
138 column![titlebar(state), content(state)]
139 .width(Length::Fill)
140 .height(Length::Fill)
141 .into()
142 };
143
144 container(shell)
145 .width(Length::Fill)
146 .height(Length::Fill)
147 .style(|_| app_shell_style())
148 .into()
149}
150
151fn custom_resize_shell(state: &CustomTitlebarDemo) -> Element<'_, Message> {
152 let base = column![titlebar(state), content(state)]
153 .width(Length::Fill)
154 .height(Length::Fill);
155
156 stack![base, custom_resize_overlay()]
157 .width(Length::Fill)
158 .height(Length::Fill)
159 .into()
160}
161
162fn custom_resize_overlay() -> Element<'static, Message> {
163 let gutter = Length::Fixed(RESIZE_GUTTER);
164
165 column![
166 row![
167 resize_handle(window::Direction::NorthWest, gutter, gutter),
168 resize_handle(window::Direction::North, Length::Fill, gutter),
169 resize_handle(window::Direction::NorthEast, gutter, gutter),
170 ]
171 .width(Length::Fill)
172 .height(gutter),
173 row![
174 resize_handle(window::Direction::West, gutter, Length::Fill),
175 Space::new().width(Length::Fill).height(Length::Fill),
176 resize_handle(window::Direction::East, gutter, Length::Fill),
177 ]
178 .width(Length::Fill)
179 .height(Length::Fill),
180 row![
181 resize_handle(window::Direction::SouthWest, gutter, gutter),
182 resize_handle(window::Direction::South, Length::Fill, gutter),
183 resize_handle(window::Direction::SouthEast, gutter, gutter),
184 ]
185 .width(Length::Fill)
186 .height(gutter),
187 ]
188 .width(Length::Fill)
189 .height(Length::Fill)
190 .into()
191}
192
193fn resize_handle(
194 direction: window::Direction,
195 width: impl Into<Length>,
196 height: impl Into<Length>,
197) -> Element<'static, Message> {
198 mouse_area(Space::new().width(width.into()).height(height.into()))
199 .interaction(resize_interaction(direction))
200 .on_enter(Message::ResizeHover(Some(direction)))
201 .on_exit(Message::ResizeHover(None))
202 .on_press(Message::Resize(direction))
203 .into()
204}
205
206fn resize_interaction(direction: window::Direction) -> mouse::Interaction {
207 match direction {
208 window::Direction::North | window::Direction::South => {
209 mouse::Interaction::ResizingVertically
210 }
211 window::Direction::East | window::Direction::West => {
212 mouse::Interaction::ResizingHorizontally
213 }
214 window::Direction::NorthEast | window::Direction::SouthWest => {
215 mouse::Interaction::ResizingDiagonallyUp
216 }
217 window::Direction::NorthWest | window::Direction::SouthEast => {
218 mouse::Interaction::ResizingDiagonallyDown
219 }
220 }
221}
222
223#[cfg(target_os = "linux")]
224fn set_resize_hover_cursor<Message>(
225 id: window::Id,
226 direction: Option<window::Direction>,
227) -> Task<Message>
228where
229 Message: Send + 'static,
230{
231 window::run(id, move |native| {
232 let _ = apply_x11_resize_cursor(native, direction);
233 })
234 .discard()
235}
236
237#[cfg(not(target_os = "linux"))]
238fn set_resize_hover_cursor<Message>(
239 _id: window::Id,
240 _direction: Option<window::Direction>,
241) -> Task<Message>
242where
243 Message: Send + 'static,
244{
245 Task::none()
246}
247
248#[cfg(target_os = "linux")]
249fn apply_x11_resize_cursor(
250 native: &dyn iced::window::Window,
251 direction: Option<window::Direction>,
252) -> Result<(), ()> {
253 use raw_window_handle::RawWindowHandle;
254 use x11rb::NONE;
255 use x11rb::connection::Connection;
256 use x11rb::protocol::xproto::{self, ConnectionExt as _, FontWrapper, Window};
257
258 const XC_BOTTOM_LEFT_CORNER: u16 = 12;
259 const XC_BOTTOM_RIGHT_CORNER: u16 = 14;
260 const XC_BOTTOM_SIDE: u16 = 16;
261 const XC_LEFT_SIDE: u16 = 70;
262 const XC_RIGHT_SIDE: u16 = 96;
263 const XC_TOP_LEFT_CORNER: u16 = 134;
264 const XC_TOP_RIGHT_CORNER: u16 = 136;
265 const XC_TOP_SIDE: u16 = 138;
266
267 let raw_window = native.window_handle().map_err(|_| ())?;
268 let window = match raw_window.as_raw() {
269 RawWindowHandle::Xlib(handle) => handle.window as Window,
270 RawWindowHandle::Xcb(handle) => handle.window.get() as Window,
271 _ => return Ok(()),
272 };
273
274 let (conn, _) = x11rb::connect(None).map_err(|_| ())?;
275
276 if let Some(direction) = direction {
277 let glyph = match direction {
278 window::Direction::North => XC_TOP_SIDE,
279 window::Direction::South => XC_BOTTOM_SIDE,
280 window::Direction::East => XC_RIGHT_SIDE,
281 window::Direction::West => XC_LEFT_SIDE,
282 window::Direction::NorthEast => XC_TOP_RIGHT_CORNER,
283 window::Direction::NorthWest => XC_TOP_LEFT_CORNER,
284 window::Direction::SouthEast => XC_BOTTOM_RIGHT_CORNER,
285 window::Direction::SouthWest => XC_BOTTOM_LEFT_CORNER,
286 };
287
288 let cursor = conn.generate_id().map_err(|_| ())?;
289 let font = FontWrapper::open_font(&conn, b"cursor").map_err(|_| ())?;
290
291 conn.create_glyph_cursor(
292 cursor,
293 font.font(),
294 font.font(),
295 glyph,
296 glyph + 1,
297 0,
298 0,
299 0,
300 u16::MAX,
301 u16::MAX,
302 u16::MAX,
303 )
304 .map_err(|_| ())?;
305
306 conn.change_window_attributes(
307 window,
308 &xproto::ChangeWindowAttributesAux::default().cursor(cursor),
309 )
310 .map_err(|_| ())?;
311
312 conn.free_cursor(cursor).map_err(|_| ())?;
313 } else {
314 conn.change_window_attributes(
315 window,
316 &xproto::ChangeWindowAttributesAux::default().cursor(NONE),
317 )
318 .map_err(|_| ())?;
319 }
320
321 conn.flush().map_err(|_| ())?;
322 Ok(())
323}
324
325fn titlebar(state: &CustomTitlebarDemo) -> Element<'_, Message> {
326 let lead_inset = Space::new().width(state.platform.leading_inset());
327
328 let drag_content = row![
329 title_label(),
330 Space::new().width(Length::Fill),
331 status_pill(state.platform.label())
332 ]
333 .spacing(10)
334 .align_y(alignment::Vertical::Center)
335 .width(Length::Fill);
336
337 let drag_strip: Element<'_, Message> = if state.platform.can_drag_titlebar() {
338 mouse_area(
339 container(drag_content)
340 .width(Length::Fill)
341 .height(Length::Fill)
342 .center_y(Length::Fill),
343 )
344 .on_press(Message::StartWindowDrag)
345 .on_double_click(Message::ToggleMaximize)
346 .into()
347 } else {
348 container(drag_content)
349 .width(Length::Fill)
350 .height(Length::Fill)
351 .center_y(Length::Fill)
352 .into()
353 };
354
355 let buttons: Element<'_, Message> = if state.platform.shows_custom_caption_buttons() {
356 row![
357 caption_button(CaptionGlyph::Minimize, Message::Minimize, false),
358 caption_button(CaptionGlyph::Maximize, Message::ToggleMaximize, false),
359 caption_button(CaptionGlyph::Close, Message::Close, true),
360 ]
361 .spacing(8)
362 .align_y(alignment::Vertical::Center)
363 .into()
364 } else {
365 Space::new().width(Length::Shrink).into()
366 };
367
368 container(
369 row![lead_inset, drag_strip, buttons]
370 .spacing(12)
371 .align_y(alignment::Vertical::Center)
372 .width(Length::Fill)
373 .height(Length::Fill),
374 )
375 .height(TITLEBAR_HEIGHT)
376 .padding([0, 18])
377 .style(|_| titlebar_style())
378 .into()
379}
380
381fn content(state: &CustomTitlebarDemo) -> Element<'_, Message> {
382 let body = column![
383 hero_card(state),
384 row![
385 info_card(
386 "Native",
387 state.platform.native_edges(),
388 Color::from_rgb8(234, 162, 86),
389 ),
390 info_card(
391 "Custom",
392 state.platform.custom_layers(),
393 Color::from_rgb8(92, 154, 138),
394 ),
395 ]
396 .spacing(18),
397 ]
398 .spacing(18)
399 .padding([18, 18]);
400
401 scrollable(body).height(Length::Fill).into()
402}
403
404fn hero_card(state: &CustomTitlebarDemo) -> Element<'_, Message> {
405 let summary = match state.platform {
406 PlatformFlavor::Windows => {
407 "Windows uses a frameless window with custom controls and app-drawn resize handles."
408 }
409 PlatformFlavor::Macos => {
410 "macOS keeps the traffic lights and native resize border while matching the same header layout."
411 }
412 PlatformFlavor::LinuxX11 => "X11 draws the whole header and resize handles in the app.",
413 PlatformFlavor::LinuxWayland => "Wayland keeps the header visual-only.",
414 PlatformFlavor::Other => "Unsupported platforms fall back to the shared header layout.",
415 };
416
417 container(
418 column![
419 text("Unified Custom Titlebar")
420 .size(36)
421 .color(Color::from_rgb8(35, 30, 25)),
422 text(summary).size(18).color(Color::from_rgb8(90, 82, 72)),
423 row![
424 metric_chip("Header height", "62 px"),
425 metric_chip("Platform", state.platform.label()),
426 metric_chip("Resize", state.platform.resize_mode()),
427 ]
428 .spacing(10),
429 ]
430 .spacing(14),
431 )
432 .padding(24)
433 .width(Length::Fill)
434 .style(|_| card_style(Color::from_rgb8(255, 247, 234)))
435 .into()
436}
437
438fn info_card<'a>(title: &'a str, body: &'a str, accent: Color) -> Element<'a, Message> {
439 container(
440 column![
441 text(title).size(18).color(Color::from_rgb8(38, 34, 29)),
442 text(body).size(16).color(Color::from_rgb8(86, 80, 72)),
443 ]
444 .spacing(10),
445 )
446 .padding(20)
447 .width(Length::Fill)
448 .style(move |_| accented_card_style(accent))
449 .into()
450}
451
452fn title_label<'a>() -> Element<'a, Message> {
453 text("Custom titlebar demo")
454 .size(18)
455 .color(Color::from_rgb8(245, 241, 233))
456 .into()
457}
458
459fn status_pill<'a>(label: &'a str) -> Element<'a, Message> {
460 container(text(label).size(14).color(Color::from_rgb8(232, 238, 231)))
461 .padding([8, 12])
462 .style(|_| status_style())
463 .into()
464}
465
466fn metric_chip<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> {
467 container(
468 column![
469 text(label).size(12).color(Color::from_rgb8(129, 118, 105)),
470 text(value).size(16).color(Color::from_rgb8(40, 35, 30)),
471 ]
472 .spacing(4),
473 )
474 .padding([10, 14])
475 .style(|_| metric_style())
476 .into()
477}
478
479#[derive(Debug, Clone, Copy)]
480enum CaptionGlyph {
481 Minimize,
482 Maximize,
483 Close,
484}
485
486fn caption_button(
487 glyph: CaptionGlyph,
488 message: Message,
489 danger: bool,
490) -> Element<'static, Message> {
491 button(caption_glyph(glyph))
492 .width(40)
493 .height(30)
494 .padding(0)
495 .style(move |_, status| caption_button_style(status, danger))
496 .on_press(message)
497 .into()
498}
499
500fn caption_glyph(glyph: CaptionGlyph) -> Element<'static, Message> {
501 const GLYPH: Color = Color::from_rgb8(245, 241, 233);
502
503 let icon: Element<'static, Message> = match glyph {
504 CaptionGlyph::Minimize => container(Space::new().width(12).height(2))
505 .style(|_| {
506 iced::widget::container::Style::default().background(Background::Color(GLYPH))
507 })
508 .into(),
509 CaptionGlyph::Maximize => container(Space::new().width(10).height(10))
510 .style(|_| {
511 iced::widget::container::Style::default()
512 .border(Border::default().color(GLYPH).width(1))
513 })
514 .into(),
515 CaptionGlyph::Close => text("x").size(15).color(GLYPH).into(),
516 };
517
518 container(icon)
519 .width(Length::Fill)
520 .height(Length::Fill)
521 .center_x(Length::Fill)
522 .center_y(Length::Fill)
523 .into()
524}
525
526fn app_shell_style() -> iced::widget::container::Style {
527 iced::widget::container::Style::default().background(Color::from_rgb8(244, 237, 229))
528}
529
530fn titlebar_style() -> iced::widget::container::Style {
531 iced::widget::container::Style::default()
532 .background(Color::from_rgb8(35, 42, 48))
533 .border(
534 Border::default()
535 .color(Color::from_rgb8(68, 78, 87))
536 .width(1),
537 )
538 .shadow(Shadow {
539 color: Color {
540 a: 0.18,
541 ..Color::BLACK
542 },
543 offset: Vector::new(0.0, 10.0),
544 blur_radius: 24.0,
545 })
546}
547
548fn card_style(background: Color) -> iced::widget::container::Style {
549 iced::widget::container::Style::default()
550 .background(background)
551 .border(
552 Border::default()
553 .color(Color::from_rgb8(214, 202, 188))
554 .width(1),
555 )
556 .shadow(Shadow {
557 color: Color {
558 a: 0.08,
559 ..Color::BLACK
560 },
561 offset: Vector::new(0.0, 8.0),
562 blur_radius: 20.0,
563 })
564}
565
566fn accented_card_style(accent: Color) -> iced::widget::container::Style {
567 card_style(Color::from_rgb8(252, 249, 243))
568 .border(Border::default().color(accent.scale_alpha(0.6)).width(1))
569}
570
571fn status_style() -> iced::widget::container::Style {
572 iced::widget::container::Style::default()
573 .background(Color::from_rgb8(76, 103, 88))
574 .border(
575 Border::default()
576 .color(Color::from_rgb8(106, 137, 118))
577 .width(1),
578 )
579}
580
581fn metric_style() -> iced::widget::container::Style {
582 iced::widget::container::Style::default()
583 .background(Color::from_rgb8(250, 245, 237))
584 .border(
585 Border::default()
586 .color(Color::from_rgb8(223, 214, 203))
587 .width(1),
588 )
589}
590
591fn caption_button_style(
592 status: iced::widget::button::Status,
593 danger: bool,
594) -> iced::widget::button::Style {
595 let background = match (danger, status) {
596 (true, iced::widget::button::Status::Hovered) => Color::from_rgb8(211, 94, 78),
597 (true, _) => Color::from_rgb8(168, 83, 70),
598 (false, iced::widget::button::Status::Hovered) => Color::from_rgb8(98, 109, 119),
599 (false, _) => Color::from_rgb8(72, 82, 91),
600 };
601
602 iced::widget::button::Style {
603 background: Some(Background::Color(background)),
604 text_color: Color::from_rgb8(245, 241, 233),
605 border: Border::default()
606 .color(Color::from_rgba8(255, 255, 255, 0.12))
607 .width(1),
608 shadow: Shadow::default(),
609 snap: false,
610 }
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614enum PlatformFlavor {
615 Windows,
616 Macos,
617 LinuxX11,
618 LinuxWayland,
619 Other,
620}
621
622impl PlatformFlavor {
623 fn detect() -> Self {
624 if cfg!(target_os = "windows") {
625 Self::Windows
626 } else if cfg!(target_os = "macos") {
627 Self::Macos
628 } else if cfg!(target_os = "linux") {
629 if std::env::var_os("WAYLAND_DISPLAY").is_some() {
630 Self::LinuxWayland
631 } else {
632 Self::LinuxX11
633 }
634 } else {
635 Self::Other
636 }
637 }
638
639 fn chrome_settings(self) -> ChromeSettings {
640 let mut chrome = ChromeSettings::default();
641
642 match self {
643 Self::Windows => {
644 if let Some(capabilities) = current_windows_capabilities() {
645 if capabilities.supports_dwm_visuals() {
646 chrome.windows.corner_preference = Some(WindowCornerPreference::Round);
647 }
648
649 if capabilities.supports_system_backdrop() {
650 chrome.windows.backdrop = Some(WindowsBackdrop::Mica);
651 }
652 }
653 }
654 Self::Macos => {
655 chrome.macos.titlebar = true;
656 chrome.macos.title = false;
657 chrome.macos.traffic_lights = true;
658 chrome.macos.titlebar_transparent = true;
659 chrome.macos.fullsize_content_view = true;
660 chrome.macos.titlebar_height = Some(TITLEBAR_HEIGHT_F64);
661 chrome.macos.traffic_light_offset_y = Some(MACOS_TRAFFIC_LIGHT_OFFSET);
662 chrome.macos.titlebar_separator_style = Some(MacosTitlebarSeparatorStyle::None);
663 }
664 Self::LinuxX11 => {
665 chrome.linux.decorations = false;
666 }
667 Self::LinuxWayland | Self::Other => {}
668 }
669
670 chrome
671 }
672
673 fn label(self) -> &'static str {
674 match self {
675 Self::Windows => "Windows",
676 Self::Macos => "macOS",
677 Self::LinuxX11 => "Linux X11",
678 Self::LinuxWayland => "Linux Wayland",
679 Self::Other => "Other",
680 }
681 }
682
683 fn native_edges(self) -> &'static str {
684 match self {
685 Self::Windows => {
686 "Windows still handles drag and snap, but resize comes from the app's edge handles."
687 }
688 Self::Macos => "macOS keeps the traffic lights and the native resize border.",
689 Self::LinuxX11 => {
690 "X11 uses app-provided drag regions, caption buttons, and resize handles."
691 }
692 Self::LinuxWayland => "Wayland keeps the compositor in charge.",
693 Self::Other => "No native chrome patch is applied here.",
694 }
695 }
696
697 fn custom_layers(self) -> &'static str {
698 match self {
699 Self::Windows => {
700 "Header UI, caption buttons, and invisible resize handles are drawn in iced."
701 }
702 Self::Macos => {
703 "Header UI is drawn in iced while AppKit owns the traffic lights and resize border."
704 }
705 Self::LinuxX11 => {
706 "Header UI, caption buttons, and invisible resize handles are drawn in iced."
707 }
708 Self::LinuxWayland => "The shared header visuals stay in iced.",
709 Self::Other => "The demo still renders the shared header UI.",
710 }
711 }
712
713 fn resize_mode(self) -> &'static str {
714 match self {
715 Self::Macos => "native",
716 Self::Windows | Self::LinuxX11 => "custom",
717 Self::LinuxWayland | Self::Other => "none",
718 }
719 }
720
721 fn can_drag_titlebar(self) -> bool {
722 matches!(self, Self::Windows | Self::Macos | Self::LinuxX11)
723 }
724
725 fn shows_custom_caption_buttons(self) -> bool {
726 matches!(self, Self::Windows | Self::LinuxX11)
727 }
728
729 fn uses_custom_resize_handles(self) -> bool {
730 matches!(self, Self::Windows | Self::LinuxX11)
731 }
732
733 fn leading_inset(self) -> Length {
734 if matches!(self, Self::Macos) {
735 Length::Fixed(112.0)
736 } else {
737 Length::Shrink
738 }
739 }
740}