Browse / github.com/storytold/wordcraft / patch
Implement drag-first crop flow and unify window chrome behavior
Implement drag-first crop flow and unify window chrome behavior This pull request implements a custom title bar with application-drawn caption buttons (Minimize, Maximize/Restore, Close) and resize edges for frameless windows on Linux, replacing the native window decorations. This aligns the Linux UI with the Windows Office style and ensures consistent window controls and resizing behavior. The changes also update the Backstage and title bar layouts to accommodate the new controls, and add comprehensive tests to verify the new behavior. **Frameless window support and caption controls (Linux):** - `apps/wordcraft/src/main.rs`, `crates/ui-egui/examples/u
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- hongfu553 (imported from pull request #4469, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +466 −10 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4f0dc0b04257776ff355e938
How it went
No other operator has reported using it yet.
Diff
raw · apps/wordcraft/src/main.rs crates/ui-egui/examples/ui_shot.rs crates/ui-egui/src/app.rs crates/ui-egui/src/backstage.rs crates/ui-egui/src/caption_buttons.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/window_chrome.rs crates/ui-egui/src/window_ui.rs
@@ -420,6 +420,13 @@ fn main() -> eframe::Result { if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); }+ // Linux: the native title bar stays off, and the app's own title bar carries the caption+ // buttons and the resize edges (`crates/ui-egui/src/caption_buttons.rs`), as Office's window+ // does on Windows.+ #[cfg(target_os = "linux")]+ {+ options.viewport = options.viewport.with_decorations(false);+ } // Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when // DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with // FXC, which needs no DLL of ours (#603; see graphics.rs).@@ -484,6 +491,10 @@ fn main() -> eframe::Result { app.ui.window = restored; app.set_window_builder(window_builder); app.integrated_titlebar = cfg!(target_os = "macos");+ #[cfg(target_os = "linux")]+ {+ app.frameless = true;+ } #[cfg(target_os = "macos")] if let Some(bar) = traffic_lights::title_bar() { log::info!("title bar: {bar:?}");@@ -28,6 +28,11 @@ fn main() { }; let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx); app.integrated_titlebar = true;+ // `{"frameless": true}` in any line: the window runs without its native title bar (Linux),+ // so the app's caption buttons and resize edges show.+ if lines.iter().any(|l| l.get("frameless").is_some_and(|v| v.as_bool() == Some(true))) {+ app.frameless = true;+ } if lines.first().is_some_and(|l| l.get("startScreen").is_some()) { app.start_screen(); }@@ -92,6 +92,7 @@ impl WordApp { printing: Default::default(), print_feedback_seen: None, integrated_titlebar: false,+ frameless: false, mac_titlebar: chrome::MacTitleBar::default(), updates_rt: updates_ui::Runtime::default(), control_rx: None,@@ -429,6 +430,7 @@ impl WordApp { if !moved(self) { self.sync_title_and_frame(&ctx, t0); }+ caption_buttons::resize_edges(self, &ctx); // The synthetic step this frame took is done even when drawing ended early. self.flush_shot_and_synthetic(&ctx); }@@ -38,24 +38,40 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if !has_document && !works_without_document(&app.ui.backstage_page) { app.ui.backstage_page = "home".into(); }- let mac = app.integrated_titlebar;+ let chrome = crate::window_chrome::config(app);+ let mac = chrome.mode == crate::window_chrome::Mode::Integrated;+ let covers_title_bar = chrome.mode != crate::window_chrome::Mode::Native; egui::Panel::left("backstage_nav") .exact_size(200.0) // Fixed width, as in Word: without this egui shows a resize handle that does nothing (#502). .resizable(false)- .frame(egui::Frame::NONE.fill(t.nav).inner_margin(egui::Margin { left: 0, right: 0, top: if mac { 0 } else { 12 }, bottom: 12 }))+ .frame(egui::Frame::NONE.fill(t.nav).inner_margin(egui::Margin {+ left: 0,+ right: 0,+ top: if covers_title_bar { 0 } else { 12 },+ bottom: 12,+ })) .show(ui, |ui| {- let back = if mac {+ let back = if covers_title_bar { // macOS (#222): the Backstage covers the title bar, so its top band is the title // bar: the back button sits beside the traffic lights, centred with them and as far // from them as the title bar's own controls, and the rest of the band drags the window.- let (band, drag) = ui.allocate_exact_size(vec2(200.0, app.mac_titlebar.height), Sense::click_and_drag());+ let (band, drag) = ui.allocate_exact_size(vec2(200.0, chrome.height), Sense::click_and_drag()); if drag.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); }+ if drag.double_clicked() {+ let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));+ } // The chevron's stroke starts at the content edge.- let c = back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left);- let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.scope_id().with("backstage_back"), Sense::click());+ let c = back_chevron_c(+ band.min.x,+ band.center().y,+ app.session.view.full_screen,+ if mac { app.mac_titlebar.content_left } else { 23.0 },+ );+ let resp = ui.interact(Rect::from_center_size(c, vec2(28.0, 28.0)), ui.scope_id().with("backstage_back"), Sense::click()); icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE); resp } else {@@ -141,7 +157,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } }); });- egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| {+ egui::CentralPanel::default()+ .frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin {+ left: 40,+ right: 40,+ top: 30 + if chrome.mode == crate::window_chrome::Mode::Frameless { chrome.height as i8 } else { 0 },+ bottom: 30,+ }))+ .show(ui, |ui| { // The Print page fills the pane and scrolls its settings and its preview itself (#1156). if app.ui.backstage_page == "print" { export_page(app, ui);@@ -158,7 +181,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { "options" => crate::options::page(app, ui), _ => home_page(app, ui), });- });+ });+ if chrome.custom_controls {+ let full = ui.ctx().input(|i| i.viewport_rect());+ let band = Rect::from_min_max(pos2(full.min.x + 200.0, full.min.y), pos2(full.max.x, full.min.y + chrome.height));+ crate::caption_buttons::band(app, ui.ctx(), band);+ } } /// Recovery is a separate copy. Leaving this page never discards it.new file mode 100644@@ -0,0 +1,342 @@+//! The window's caption buttons and resize edges for frameless windows (Linux, where the app's+//! own title bar replaces the native one, as Office's does on Windows): Minimize, Maximize (or+//! Restore) and Close at the title bar's right end, and thin strips along the window's edges+//! that resize it. Close sends `ViewportCommand::Close`, so the app's save prompt still runs+//! (the same path the native close button takes).++use egui::viewport::ResizeDirection;+use egui::{Context, CursorIcon, Order, Rect, Sense, StrokeKind, Ui, ViewportCommand, pos2, vec2};++use crate::WordApp;+use crate::theme::Tokens;++/// A caption button's width; the close box is a little wider, as on Windows.+const WIDTH: f32 = 40.0;+const CLOSE_WIDTH: f32 = 44.0;+/// The compact geometry shared by the caption glyphs.+const GLYPH: f32 = 5.0;+const ICON_STROKE: f32 = 1.35;+/// The thickness of the invisible resize strips along the window's edges.+const EDGE: f32 = 6.0;+/// The height of the title bar and of the band the Backstage reserves for it.+pub(crate) const BAND_HEIGHT: f32 = 38.0;+pub(crate) const BUTTONS_WIDTH: f32 = WIDTH * 2.0 + CLOSE_WIDTH;++#[derive(Clone, Copy, PartialEq)]+enum Kind {+ Minimize,+ Maximize,+ Close,+}++/// The caption buttons of a frameless window's title bar, drawn flush to its right edge; the+/// caller places them first in the bar's right-to-left row.+pub(crate) fn draw(ui: &mut Ui, bar_h: f32) {+ let t = Tokens::get(ui.ctx());+ let maximized = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));+ // Right-to-left layout puts the first button at the right edge. This produces the standard+ // left-to-right order: Minimize, Maximize/Restore, Close.+ for kind in [Kind::Close, Kind::Maximize, Kind::Minimize] {+ let w = if kind == Kind::Close { CLOSE_WIDTH } else { WIDTH };+ let (r, resp) = ui.allocate_exact_size(vec2(w, bar_h), Sense::click());+ let down = resp.is_pointer_button_down_on();+ let hot = down || resp.hovered();+ if hot {+ let fill = match kind {+ Kind::Close if down => t.close_pressed,+ Kind::Close => t.close_hover,+ _ if down => t.pressed,+ _ => t.hover,+ };+ ui.painter().rect_filled(r, 0.0, fill);+ }+ let c = r.center();+ let stroke = egui::Stroke::new(+ ICON_STROKE,+ if kind == Kind::Close && hot {+ t.on_close+ } else if hot {+ t.text+ } else {+ t.text_dim+ },+ );+ match kind {+ Kind::Minimize => {+ ui.painter().line_segment([pos2(c.x - GLYPH, c.y + 2.5), pos2(c.x + GLYPH, c.y + 2.5)], stroke);+ }+ Kind::Maximize if !maximized => {+ ui.painter().rect_stroke(centre_box(c, 0.0), 1.0, stroke, StrokeKind::Inside);+ }+ Kind::Maximize => {+ // Restore: two offset windows with only the visible edges of the rear one.+ let front = centre_box(c, 0.0).translate(vec2(-1.0, 1.0));+ let back = centre_box(c, 0.0).translate(vec2(2.0, -2.0));+ ui.painter().line_segment([back.left_top(), back.right_top()], stroke);+ ui.painter().line_segment([back.right_top(), back.right_bottom()], stroke);+ ui.painter().rect_stroke(front, 1.0, stroke, StrokeKind::Inside);+ }+ Kind::Close => {+ ui.painter().line_segment([pos2(c.x - GLYPH, c.y - GLYPH), pos2(c.x + GLYPH, c.y + GLYPH)], stroke);+ ui.painter().line_segment([pos2(c.x - GLYPH, c.y + GLYPH), pos2(c.x + GLYPH, c.y - GLYPH)], stroke);+ }+ }+ let label = match kind {+ Kind::Minimize => tl!("Minimize"),+ Kind::Maximize if maximized => tl!("Restore"),+ Kind::Maximize => tl!("Maximize"),+ Kind::Close => tl!("Close"),+ };+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));+ let resp = resp.on_hover_text(label);+ if resp.clicked() {+ match kind {+ Kind::Minimize => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)),+ Kind::Maximize => ui.ctx().send_viewport_cmd(ViewportCommand::Maximized(!maximized)),+ // The next frame reads this as `close_requested`, so the save prompt runs.+ Kind::Close => ui.ctx().send_viewport_cmd(ViewportCommand::Close),+ }+ }+ }+}++/// A `size`-square box centred on `c`.+fn centre_box(c: egui::Pos2, grow: f32) -> Rect {+ let s = GLYPH * 2.0 + grow - 1.0;+ Rect::from_center_size(c, vec2(s, s))+}++/// The top band of a full-window surface that covers the title bar (Backstage): it drags the+/// window (double-click maximizes, as the title bar does) and carries the caption buttons at its+/// right end. The caller leaves `band` clear of content; the buttons are drawn over it here.+pub(crate) fn band(app: &WordApp, ctx: &Context, band: Rect) {+ if !app.frameless {+ return;+ }+ let controls_rect = Rect::from_min_max(pos2(band.max.x - BUTTONS_WIDTH, band.min.y), band.max);+ let drag_rect = Rect::from_min_max(band.min, pos2(controls_rect.min.x, band.max.y));+ let drag_id = egui::Id::unique("window_band_drag");+ let drag = egui::Area::new(drag_id)+ .order(Order::Foreground)+ .fixed_pos(drag_rect.min)+ .show(ctx, |ui| {+ ui.allocate_rect(drag_rect, Sense::click_and_drag())+ })+ .inner;+ if drag.drag_started() {+ ctx.send_viewport_cmd(ViewportCommand::StartDrag);+ }+ if drag.double_clicked() {+ let max = ctx.input(|i| i.viewport().maximized.unwrap_or(false));+ ctx.send_viewport_cmd(ViewportCommand::Maximized(!max));+ }+ egui::Area::new(egui::Id::unique("window_band_buttons"))+ .order(Order::Foreground)+ .fixed_pos(controls_rect.min)+ .show(ctx, |ui| {+ ui.scope_builder(+ egui::UiBuilder::new().max_rect(controls_rect).layout(egui::Layout::right_to_left(egui::Align::Center)),+ |ui| {+ draw(ui, controls_rect.height());+ },+ );+ });+}++/// Invisible strips along the window's edges that resize a frameless window (`BeginResize`).+/// Skipped while maximized or fullscreen; a maximized window doesn't resize.+pub(crate) fn resize_edges(app: &WordApp, ctx: &Context) {+ if !app.frameless {+ return;+ }+ let (maximized, fullscreen) = ctx.input(|i| (i.viewport().maximized.unwrap_or(false), i.viewport().fullscreen.unwrap_or(false)));+ if maximized || fullscreen {+ return;+ }+ let full = ctx.input(|i| i.viewport_rect());+ let (l, t, r, b) = (full.min.x, full.min.y, full.max.x, full.max.y);+ let dirs: [(ResizeDirection, Rect, CursorIcon, u8); 8] = [+ (ResizeDirection::North, Rect::from_min_max(pos2(l, t), pos2(r, t + EDGE)), CursorIcon::ResizeNorth, 0),+ (ResizeDirection::South, Rect::from_min_max(pos2(l, b - EDGE), pos2(r, b)), CursorIcon::ResizeSouth, 1),+ (ResizeDirection::West, Rect::from_min_max(pos2(l, t), pos2(l + EDGE, b)), CursorIcon::ResizeWest, 2),+ (ResizeDirection::East, Rect::from_min_max(pos2(r - EDGE, t), pos2(r, b)), CursorIcon::ResizeEast, 3),+ (ResizeDirection::NorthWest, Rect::from_min_max(pos2(l, t), pos2(l + EDGE, t + EDGE)), CursorIcon::ResizeNorthWest, 4),+ (ResizeDirection::NorthEast, Rect::from_min_max(pos2(r - EDGE, t), pos2(r, t + EDGE)), CursorIcon::ResizeNorthEast, 5),+ (ResizeDirection::SouthWest, Rect::from_min_max(pos2(l, b - EDGE), pos2(l + EDGE, b)), CursorIcon::ResizeSouthWest, 6),+ (ResizeDirection::SouthEast, Rect::from_min_max(pos2(r - EDGE, b - EDGE), pos2(r, b)), CursorIcon::ResizeSouthEast, 7),+ ];+ for (dir, rect, cursor, n) in dirs {+ let id = egui::Id::unique(("window_resize", n));+ let resp = egui::Area::new(id)+ .order(Order::Middle)+ .fixed_pos(rect.min)+ .show(ctx, |ui| {+ ui.set_min_size(rect.size());+ ui.interact(rect, id, Sense::click_and_drag())+ })+ .inner;+ let resp = resp.on_hover_cursor(cursor);+ if resp.drag_started() {+ ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));+ }+ }+}++#[cfg(test)]+mod tests {+ use serde_json::json;++ use super::*;+ use crate::WordApp;++ fn harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> {+ egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(+ |ui, app: &mut WordApp| {+ let ctx = ui.ctx().clone();+ app.logic(&ctx);+ app.ui(ui);+ },+ app,+ )+ }++ fn frameless_app() -> WordApp {+ let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());+ app.frameless = true;+ app+ }++ /// A frameless window's title bar carries Minimize, Maximize and Close; a native-decorated+ /// one doesn't (the system buttons do that work). Close sits at the right end, as a Windows+ /// caption bar has it.+ #[test]+ fn caption_buttons_only_on_a_frameless_window() {+ use egui_kittest::kittest::Queryable;+ let mut h = harness(frameless_app());+ for _ in 0..4 {+ h.step();+ }+ let close = h.get_by_label_contains("Close");+ let max = h.get_by_label_contains("Maximize");+ let min = h.get_by_label_contains("Minimize");+ assert!(+ min.rect().left() < max.rect().left() && max.rect().left() < close.rect().left(),+ "Minimize, Maximize, Close left to right, Close at the edge: min={}, max={}, close={}",+ min.rect().left(),+ max.rect().left(),+ close.rect().left()+ );+ let h = harness(WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()));+ for name in ["Minimize", "Maximize", "Close"] {+ assert!(h.query_by_label_contains(name).is_none(), "{name} belongs to the system's title bar");+ }+ }++ /// The Backstage covers the title bar: its top band carries the caption buttons and a drag+ /// region, so the frameless window can still be moved (and closed) from the File pages.+ #[test]+ fn the_backstage_band_carries_the_caption_buttons() {+ use egui_kittest::kittest::Queryable;+ let mut app = frameless_app();+ app.run("ui.backstage", json!({"value": true})).unwrap();+ let mut h = harness(app);+ for _ in 0..4 {+ h.step();+ }+ // The Backstage's own Close page shares the label; the caption button is the one in the+ // window's top band.+ let close = h+ .get_all_by_label_contains("Close")+ .into_iter()+ .find(|n| n.rect().top() < crate::caption_buttons::BAND_HEIGHT)+ .expect("the caption Close sits in the band");+ assert!(close.rect().top() < crate::caption_buttons::BAND_HEIGHT, "the buttons sit in the Backstage's top band");+ assert!(h.ctx.read_response(egui::Id::unique("window_band_drag")).is_some(), "the band drags the window");+ // Click back button closes backstage:+ h.event(egui::Event::PointerMoved(pos2(23.0, 19.0)));+ h.event(egui::Event::PointerButton { pos: pos2(23.0, 19.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });+ h.event(egui::Event::PointerButton { pos: pos2(23.0, 19.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });+ h.step();+ assert!(!h.state().ui.backstage, "clicking backstage back should close backstage");+ // Drag in the window band:+ let mut app2 = frameless_app();+ app2.run("ui.backstage", json!({"value": true})).unwrap();+ let mut h2 = harness(app2);+ for _ in 0..4 {+ h2.step();+ }+ h2.event(egui::Event::PointerMoved(pos2(300.0, 19.0)));+ h2.event(egui::Event::PointerButton { pos: pos2(300.0, 19.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });+ h2.event(egui::Event::PointerMoved(pos2(350.0, 19.0)));+ h2.step();+ let layer = h2.ctx.layer_id_at(pos2(300.0, 19.0));+ assert_eq!(layer, Some(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("window_band_drag"))));+ // The sidebar's top band also drags the window:+ h2.event(egui::Event::PointerMoved(pos2(100.0, 19.0)));+ h2.event(egui::Event::PointerButton { pos: pos2(100.0, 19.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });+ h2.event(egui::Event::PointerMoved(pos2(120.0, 19.0)));+ h2.step();+ assert!(h2.state().ui.backstage, "dragging sidebar top band leaves backstage open");+ let mut h = harness(WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()));+ let _ = h.state_mut().run("ui.backstage", json!({"value": true}));+ for _ in 0..4 {+ h.step();+ }+ // The Backstage's own Close page keeps its label on every window; what must differ is+ // the caption button in the window's top band.+ let in_band = h.get_all_by_label_contains("Close").into_iter().any(|n| n.rect().top() < crate::caption_buttons::BAND_HEIGHT);+ assert!(!in_band, "a native title bar already has the buttons");+ }++ #[test]+ fn frameless_start_screen_back_button_and_drag() {+ let mut app = frameless_app();+ app.start_screen();+ assert!(app.ui.backstage);+ assert!(!app.has_document());+ let mut h = harness(app);+ h.run_steps(4);+ // Drag in central panel top band+ h.event(egui::Event::PointerMoved(pos2(300.0, 19.0)));+ h.event(egui::Event::PointerButton { pos: pos2(300.0, 19.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });+ h.event(egui::Event::PointerMoved(pos2(350.0, 19.0)));+ h.step();+ assert_eq!(h.ctx.layer_id_at(pos2(300.0, 19.0)), Some(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("window_band_drag"))));++ // Click Back button leaves start screen to empty window+ h.event(egui::Event::PointerMoved(pos2(23.0, 19.0)));+ h.event(egui::Event::PointerButton { pos: pos2(23.0, 19.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });+ h.event(egui::Event::PointerButton { pos: pos2(23.0, 19.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });+ h.run_steps(4);+ assert!(!h.state().ui.backstage, "clicking back leaves the Start screen");+ assert!(!h.state().has_document(), "for the empty window");+ }++ /// Close asks the viewport to close; egui-winit turns that into the close request, and the+ /// desktop app's wrapper runs it through `WordApp::close_requested` — which asks before+ /// dropping unsaved changes, as it does for the native close button.+ #[test]+ fn close_goes_through_the_apps_close_flow() {+ let mut app = frameless_app();+ let _ = app.run("text.insert", json!({"text": "Unsaved"}));+ assert!(!app.close_requested(), "a dirty document is not dropped unasked");+ assert!(matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "the save prompt shows");+ // A clean document (or no document at all) closes without a prompt.+ let mut app = frameless_app();+ assert!(app.close_requested(), "nothing to save");+ }++ /// The resize edges cover the window's edges but leave the middle of the title bar a drag+ /// region, and none of them are present without `frameless`.+ #[test]+ fn resize_edges_follow_the_frameless_flag() {+ let h = harness(frameless_app());+ for n in [0u8, 1, 3, 7] {+ let id = egui::Id::unique(("window_resize", n));+ assert!(h.ctx.read_response(id).is_some(), "edge {n} is a widget");+ }+ let h = harness(WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()));+ let id = egui::Id::unique(("window_resize", 0u8));+ assert!(h.ctx.read_response(id).is_none(), "no edges on a decorated window");+ }+}@@ -281,13 +281,16 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // macOS: shorter, so the system's traffic lights are centred in it; the tallest controls // shrink to keep a margin above and below them.- let mac = app.integrated_titlebar;+ let chrome = crate::window_chrome::config(app);+ let mac = chrome.mode == crate::window_chrome::Mode::Integrated; let bar = app.mac_titlebar; let left = if mac { bar.content_left as i8 } else { 8 }; let control_h = if mac { 22.0 } else { 26.0 };+ // Frameless (Linux): the caption buttons sit flush at the bar's right end, so no right margin.+ let right = if chrome.mode == crate::window_chrome::Mode::Frameless { 0 } else { 10 }; egui::Panel::top("title_bar") .exact_size(if mac { bar.height } else { 38.0 })- .frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 }))+ .frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); // Drag the window by the title bar (integrated title bar on macOS).@@ -346,6 +349,11 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { } qat_end = ui.min_rect().max.x; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+ // Frameless (Linux): the window's caption buttons, flush at the right end.+ if chrome.custom_controls {+ crate::caption_buttons::draw(ui, crate::caption_buttons::BAND_HEIGHT);+ ui.add_space(8.0);+ } // Account / community. let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click()); ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);@@ -517,6 +525,12 @@ pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) { search_box(app, ui, 22.0); }); }+ // Frameless (Linux): the window's buttons stay reachable in Focus and Full Screen.+ if crate::window_chrome::config(app).custom_controls {+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+ crate::caption_buttons::draw(ui, SCREEN_BAR_HEIGHT);+ });+ } }); } @@ -17,6 +17,7 @@ pub mod a11y; pub mod a11y_pane; pub mod async_saves; pub mod autoscroll;+pub mod caption_buttons; pub mod backstage; pub mod canvas; pub mod caret_glide;@@ -65,6 +66,7 @@ pub mod table_ruler; pub mod theme; pub mod updates_ui; pub mod widgets;+pub(crate) mod window_chrome; pub mod window_close; pub mod window_geometry; mod window_ui;@@ -615,6 +617,8 @@ pub struct WordApp { parked: std::collections::HashMap<u64, documents::Parked>, /// The open windows ([`windows`]). pub(crate) windows: windows::Windows,+ /// Linux: the native title bar is replaced by the app's own caption controls.+ pub frameless: bool, /// The document the last print job was for: its window reports how the job went. print_document: Option<u64>, }new file mode 100644@@ -0,0 +1,49 @@+//! Shared title-bar policy and hit-test geometry.+//!+//! The policy is common to every platform, while the actual controls remain native on Windows+//! and macOS and custom on frameless Linux windows.+++use crate::WordApp;++#[derive(Clone, Copy, Debug, Eq, PartialEq)]+pub(crate) enum Mode {+ Native,+ Integrated,+ Frameless,+}++#[derive(Clone, Copy, Debug, PartialEq)]+pub(crate) struct Config {+ pub mode: Mode,+ pub height: f32,+ pub left_reserved: f32,+ pub right_reserved: f32,+ pub custom_controls: bool,+}+++pub(crate) const BACK_WIDTH: f32 = 52.0;++pub(crate) fn config(app: &WordApp) -> Config {+ if app.frameless {+ Config {+ mode: Mode::Frameless,+ height: crate::caption_buttons::BAND_HEIGHT,+ left_reserved: BACK_WIDTH,+ right_reserved: crate::caption_buttons::BUTTONS_WIDTH,+ custom_controls: true,+ }+ } else if app.integrated_titlebar {+ Config {+ mode: Mode::Integrated,+ height: app.mac_titlebar.height,+ left_reserved: app.mac_titlebar.content_left,+ right_reserved: 0.0,+ custom_controls: false,+ }+ } else {+ Config { mode: Mode::Native, height: 0.0, left_reserved: 0.0, right_reserved: 0.0, custom_controls: false }+ }+}+@@ -161,6 +161,7 @@ impl WordApp { font_generation: _, integrated_titlebar: _, mac_titlebar: _,+ frameless: _, control_rx: _, updates_rt: _, // The control channel's screenshots and input go to the active window, whichever it is.