Skip to main content

custom_titlebar/
custom_titlebar.rs

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}