diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index f05b421df..f5232b17c 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -340,12 +340,16 @@ fn main() -> eframe::Result<()> { for f in &files { app.open_path(f); } + // Linux/BSD: Excel's layout — no window-manager title bar above ours; our title bar carries the + // window controls. `GRIDCRAFT_SYSTEM_TITLEBAR=1` keeps the system frame. + app.window_controls = cfg!(not(any(target_os = "macos", target_os = "windows"))) && std::env::var_os("GRIDCRAFT_SYSTEM_TITLEBAR").is_none(); let mut viewport = egui::ViewportBuilder::default() .with_title("GridCraft") .with_inner_size([1440.0, 900.0]) .with_min_inner_size([640.0, 420.0]) .with_app_id("ai.storyteller.gridcraft") - .with_drag_and_drop(true); + .with_drag_and_drop(true) + .with_decorations(!app.window_controls); if cfg!(target_os = "macos") { viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 535c91a5c..471905fbd 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -78,6 +78,8 @@ impl SheetApp { macros: macro_ui::MacroUi::default(), started: now_ms(), allow_close: false, + window_controls: false, + viewport_commands: Vec::new(), bring_forward: false, open_switch_windows: false, ui_window: None, @@ -252,6 +254,9 @@ impl SheetApp { macro_ui::question_box(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); + // Last, so its resize cursors win over the widgets under the window edges. + ribbon::window_frame(self, &ctx); + self.flush_viewport_commands(&ctx); // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. if ctx.viewport_id() == egui::ViewportId::ROOT { control::issue_screenshots(self, &ctx); @@ -304,6 +309,12 @@ impl SheetApp { } sel.active.a1() } + + pub(crate) fn flush_viewport_commands(&mut self, ctx: &egui::Context) { + for command in self.viewport_commands.drain(..) { + ctx.send_viewport_cmd(command); + } + } } #[cfg(test)] diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 4825318bc..1963e7054 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -163,6 +163,26 @@ impl SheetApp { /// UI-only commands (dialogs, view toggles that aren't saved in the file). fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { let r = match id { + "ui.window.minimize" => { + self.viewport_commands.push(egui::ViewportCommand::Minimized(true)); + Ok(Json::Null) + } + "ui.window.maximize" => { + let on = p.get("on").and_then(Json::as_bool).unwrap_or(true); + self.viewport_commands.push(egui::ViewportCommand::Maximized(on)); + Ok(Json::Null) + } + "ui.window.close" => { + self.viewport_commands.push(egui::ViewportCommand::Close); + Ok(Json::Null) + } + "ui.window.beginResize" => { + let Some(direction) = p.get("direction").and_then(Json::as_str).and_then(crate::parse_resize_direction) else { + return Some(Err("direction must be north, south, east, west, northeast, northwest, southeast, or southwest".into())); + }; + self.viewport_commands.push(egui::ViewportCommand::BeginResize(direction)); + Ok(Json::Null) + } "app.checkForUpdates" | "help.checkForUpdates" => { self.check_for_updates(); Ok(Json::Null) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 602d5ccdf..7d7daf006 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -275,6 +275,10 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"), ("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), + ("ui.window.minimize", "Minimize Window", ""), + ("ui.window.maximize", "Maximize Window", "{on: bool}"), + ("ui.window.close", "Close Window", ""), + ("ui.window.beginResize", "Resize Window", "{direction: north|south|east|west|northeast|northwest|southeast|southwest}"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"), ( diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index eaee085cf..a2d1312be 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -278,6 +278,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Uložit", "Save As" => "Uložit jako", "Export" => "Exportovat", + "Minimize" => "Minimalizovat", + "Maximize" => "Maximalizovat", + "Restore Down" => "Obnovit dolů", "Close" => "Zavřít", "Options" => "Možnosti", "Account" => "Účet", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 07fdb6710..fa4303851 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -294,6 +294,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Speichern", "Save As" => "Speichern unter", "Export" => "Exportieren", + "Minimize" => "Minimieren", + "Maximize" => "Maximieren", + "Restore Down" => "Nach unten wiederherstellen", "Close" => "Schließen", "Options" => "Optionen", "Account" => "Konto", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index ec42b3473..2a7449fd6 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -271,6 +271,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Αποθήκευση", "Save As" => "Αποθήκευση ως", "Export" => "Εξαγωγή", + "Minimize" => "Ελαχιστοποίηση", + "Maximize" => "Μεγιστοποίηση", + "Restore Down" => "Επαναφορά", "Close" => "Κλείσιμο", "Options" => "Επιλογές", "Blank workbook" => "Κενό βιβλίο εργασίας", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index db78d8105..df0fb4384 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -283,6 +283,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Guardar", "Save As" => "Guardar como", "Export" => "Exportar", + "Minimize" => "Minimizar", + "Maximize" => "Maximizar", + "Restore Down" => "Restaurar", "Close" => "Cerrar", "Options" => "Opciones", "Account" => "Cuenta", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fd5e23b5b..31e42afe5 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -370,6 +370,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Enregistrer", "Save As" => "Enregistrer sous", "Export" => "Exporter", + "Minimize" => "Réduire", + "Maximize" => "Agrandir", + "Restore Down" => "Restaurer", "Close" => "Fermer", "Options" => "Options", "Account" => "Compte", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index dcede52cc..24fa45c95 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -365,6 +365,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Salva", "Save As" => "Salva con nome", "Export" => "Esporta", + "Minimize" => "Riduci a icona", + "Maximize" => "Ingrandisci", + "Restore Down" => "Ripristina", "Close" => "Chiudi", "Options" => "Opzioni", "Blank workbook" => "Cartella di lavoro vuota", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index d558be6ec..a659da744 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -364,6 +364,9 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Save", "上書き保存"), ("Save As", "名前を付けて保存"), ("Export", "エクスポート"), + ("Minimize", "最小化"), + ("Maximize", "最大化"), + ("Restore Down", "元のサイズに戻す"), ("Close", "閉じる"), ("Options", "オプション"), ("Blank workbook", "空白のブック"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index ca2710272..9d6d3644a 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -370,6 +370,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "저장", "Save As" => "다른 이름으로 저장", "Export" => "내보내기", + "Minimize" => "최소화", + "Maximize" => "최대화", + "Restore Down" => "이전 크기로 복원", "Close" => "닫기", "Options" => "옵션", "Account" => "계정", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 64c24b1f2..44a1b3815 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -364,6 +364,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Opslaan", "Save As" => "Opslaan als", "Export" => "Exporteren", + "Minimize" => "Minimaliseren", + "Maximize" => "Maximaliseren", + "Restore Down" => "Vorig formaat", "Close" => "Sluiten", "Options" => "Opties", "Blank workbook" => "Lege werkmap", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 29264ce91..d232a6e0d 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -357,6 +357,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Zapisz", "Save As" => "Zapisz jako", "Export" => "Eksportuj", + "Minimize" => "Minimalizuj", + "Maximize" => "Maksymalizuj", + "Restore Down" => "Przywróć w dół", "Close" => "Zamknij", "Options" => "Opcje", "Blank workbook" => "Pusty skoroszyt", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 3cc4faf76..d93054309 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -367,6 +367,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Salvar", "Save As" => "Salvar como", "Export" => "Exportar", + "Minimize" => "Minimizar", + "Maximize" => "Maximizar", + "Restore Down" => "Restaurar", "Close" => "Fechar", "Options" => "Opções", "Account" => "Conta", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 3d5a193d1..0876ac50f 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -364,6 +364,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Salvare", "Save As" => "Salvare ca", "Export" => "Exportare", + "Minimize" => "Minimizare", + "Maximize" => "Maximizare", + "Restore Down" => "Restaurare", "Close" => "Închidere", "Options" => "Opțiuni", "Blank workbook" => "Registru gol", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 2d4d3fe1f..f422bcb93 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -372,6 +372,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Сохранить", "Save As" => "Сохранить как", "Export" => "Экспорт", + "Minimize" => "Свернуть", + "Maximize" => "Развернуть", + "Restore Down" => "Восстановить", "Close" => "Закрыть", "Options" => "Параметры", "Account" => "Учетная запись", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index a0eac0a4f..fa9d1285a 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -363,6 +363,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Sačuvaj", "Save As" => "Sačuvaj kao", "Export" => "Izvezi", + "Minimize" => "Minimizuj", + "Maximize" => "Maksimizuj", + "Restore Down" => "Vrati", "Close" => "Zatvori", "Options" => "Opcije", "Account" => "Nalog", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index ed3d0bf01..35143bf9a 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -277,6 +277,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Kaydet", "Save As" => "Farklı Kaydet", "Export" => "Dışarı Aktar", + "Minimize" => "Simge durumuna küçült", + "Maximize" => "Ekranı kapla", + "Restore Down" => "Önceki boyuta getir", "Close" => "Kapat", "Options" => "Seçenekler", "Blank workbook" => "Boş çalışma kitabı", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 7059a0ff4..4374cc135 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -361,6 +361,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Зберегти", "Save As" => "Зберегти як", "Export" => "Експорт", + "Minimize" => "Згорнути", + "Maximize" => "Розгорнути", + "Restore Down" => "Відновити", "Close" => "Закрити", "Options" => "Параметри", "Blank workbook" => "Порожня книга", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 260f9572d..62462cc4b 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -367,6 +367,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "Lưu", "Save As" => "Lưu thành", "Export" => "Xuất", + "Minimize" => "Thu nhỏ", + "Maximize" => "Phóng to", + "Restore Down" => "Khôi phục", "Close" => "Đóng", "Options" => "Tùy chọn", "Blank workbook" => "Sổ làm việc trống", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index ff671a7c9..e7ff3fc75 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -368,6 +368,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save" => "保存", "Save As" => "另存为", "Export" => "导出", + "Minimize" => "最小化", + "Maximize" => "最大化", + "Restore Down" => "向下还原", "Close" => "关闭", "Options" => "选项", "Account" => "账户", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ce7ce347b..3086d1fc9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -83,6 +83,20 @@ pub use services::{Chrome, Inbox, Services}; pub use state::{NoticeKind, OpenWindow, SaveAnswer, SheetView}; pub mod desktop_theme; +fn parse_resize_direction(value: &str) -> Option { + match value { + "north" => Some(egui::ResizeDirection::North), + "south" => Some(egui::ResizeDirection::South), + "east" => Some(egui::ResizeDirection::East), + "west" => Some(egui::ResizeDirection::West), + "northeast" => Some(egui::ResizeDirection::NorthEast), + "northwest" => Some(egui::ResizeDirection::NorthWest), + "southeast" => Some(egui::ResizeDirection::SouthEast), + "southwest" => Some(egui::ResizeDirection::SouthWest), + _ => None, + } +} + /// Persisted UI preferences. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -308,6 +322,10 @@ pub struct SheetApp { /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. pub allow_close: bool, + /// Linux/BSD: the window has no system frame; the title bar draws minimize, maximize and + /// close, and the window resizes from its edges (`ribbon::window_frame`). + pub window_controls: bool, + viewport_commands: Vec, /// A quit question is waiting in the active window: [`doc_windows`] brings its native window /// to the front, also when the app is hidden or the window minimized (a Dock quit). pub bring_forward: bool, @@ -341,3 +359,32 @@ pub fn clean_error(e: &str) -> String { pub fn cell(r: u32, c: u32) -> CellRef { CellRef::new(r, c) } + +#[cfg(test)] +mod viewport_command_tests { + use super::parse_resize_direction; + + #[test] + fn resize_direction_parser_accepts_all_edges_and_rejects_unknown_values() { + for name in ["north", "south", "east", "west", "northeast", "northwest", "southeast", "southwest"] { + assert!(parse_resize_direction(name).is_some(), "{name}"); + } + assert!(parse_resize_direction("middle").is_none()); + } + + #[test] + fn window_commands_queue_viewport_commands_without_a_workbook() { + use egui::ViewportCommand; + let mut a = crate::SheetApp::new(gridcraft_engine::Session::new(), crate::Services::default()); + for (id, params, expected) in [ + ("ui.window.minimize", serde_json::json!({}), ViewportCommand::Minimized(true)), + ("ui.window.maximize", serde_json::json!({"on": true}), ViewportCommand::Maximized(true)), + ("ui.window.close", serde_json::json!({}), ViewportCommand::Close), + ("ui.window.beginResize", serde_json::json!({"direction": "east"}), ViewportCommand::BeginResize(egui::ResizeDirection::East)), + ] { + assert!(a.run(id, params).is_ok(), "{id} should route without a workbook"); + assert!(a.viewport_commands.contains(&expected), "{id} queues {expected:?}"); + } + assert!(a.run("ui.window.beginResize", serde_json::json!({"direction": "diagonal"})).is_err()); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47f1eabd7..d14724f1b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -38,7 +38,7 @@ use insert::{draw, insert}; use page_layout::page_layout; use review::review; pub use shortcuts::shortcut; -pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_LEFT_PAD, title_bar}; +pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_LEFT_PAD, title_bar, window_frame}; use title_bar::{TITLE_BAR_H, TITLE_CONTROL_H}; use view::view; diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs index 29bd4f270..e6de96f20 100644 --- a/crates/ui-egui/src/ribbon/title_bar.rs +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -120,6 +120,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { }); let left_end = ui.min_rect().right(); let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if app.window_controls { + ui.add_space(3.0 * CONTROL_W); + } ui.add_space(8.0); let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch)); control_keytip(app, ui, &__k, "commandSearch"); @@ -206,6 +209,120 @@ fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32 } } +/// Width of one window-control button on the title bar. +pub const CONTROL_W: f32 = 46.0; +const CONTROL_TITLE_H: f32 = 38.0; +const RESIZE_BAND: f32 = 5.0; + +/// Draw application controls and edge resizing for frameless windows. +pub fn window_frame(app: &mut SheetApp, ctx: &egui::Context) { + if !app.window_controls { + return; + } + let (maximized, fullscreen) = ctx.input(|i| (i.viewport().maximized.unwrap_or(false), i.viewport().fullscreen.unwrap_or(false))); + if fullscreen { + return; + } + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let controls = egui::Rect::from_min_size(egui::pos2(screen.max.x - 3.0 * CONTROL_W, screen.min.y), egui::vec2(3.0 * CONTROL_W, CONTROL_TITLE_H)); + egui::Area::new(egui::Id::unique("gc_window_controls")).order(egui::Order::Foreground).fixed_pos(controls.min).show(ctx, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); + ui.horizontal(|ui| { + for kind in ["minimize", "maximize", "close"] { + let (r, resp) = ui.allocate_exact_size(egui::vec2(CONTROL_W, CONTROL_TITLE_H), egui::Sense::click()); + let label = app.ui.language.tr(match kind { + "minimize" => "Minimize", + "maximize" if maximized => "Restore Down", + "maximize" => "Maximize", + _ => "Close", + }); + let hot = resp.hovered() || resp.is_pointer_button_down_on(); + if hot { + ui.painter().rect_filled( + r, + 0.0, + if kind == "close" { + egui::Color32::from_rgb(0xC4, 0x2B, 0x1C) + } else if resp.is_pointer_button_down_on() { + t.pressed + } else { + t.hover + }, + ); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + let m = r.center(); + let stroke = egui::Stroke::new(1.0, if kind == "close" && hot { egui::Color32::WHITE } else { t.text }); + match kind { + "minimize" => { + ui.painter().line_segment([egui::pos2(m.x - 5.0, m.y), egui::pos2(m.x + 5.0, m.y)], stroke); + } + "maximize" if maximized => { + let front = egui::Rect::from_min_size(egui::pos2(m.x - 5.0, m.y - 3.0), egui::vec2(8.0, 8.0)); + ui.painter().rect_stroke(front, 0.0, stroke, egui::StrokeKind::Inside); + ui.painter().line_segment([egui::pos2(m.x - 3.0, m.y - 5.0), egui::pos2(m.x + 5.0, m.y - 5.0)], stroke); + ui.painter().line_segment([egui::pos2(m.x + 5.0, m.y - 5.0), egui::pos2(m.x + 5.0, m.y + 3.0)], stroke); + } + "maximize" => { + ui.painter().rect_stroke(r.shrink(15.0), 0.0, stroke, egui::StrokeKind::Inside); + } + _ => { + ui.painter().line_segment([m - egui::vec2(5.0, 5.0), m + egui::vec2(5.0, 5.0)], stroke); + ui.painter().line_segment([m + egui::vec2(5.0, -5.0), m - egui::vec2(5.0, -5.0)], stroke); + } + } + if resp.on_hover_text(label).clicked() { + match kind { + "minimize" => app.run_or_alert("ui.window.minimize", json!({})), + "maximize" => app.run_or_alert("ui.window.maximize", json!({"on": !maximized})), + "close" => app.run_or_alert("ui.window.close", json!({})), + _ => {} + } + } + } + }); + }); + if maximized { + return; + } + let Some(pos) = ctx.input(|i| i.pointer.hover_pos()) else { + return; + }; + let l = pos.x <= screen.min.x + RESIZE_BAND; + let r = pos.x >= screen.max.x - RESIZE_BAND; + let top = pos.y <= screen.min.y + RESIZE_BAND; + let b = pos.y >= screen.max.y - RESIZE_BAND; + let dir = match (l, r, top, b) { + (true, false, true, false) => Some(egui::viewport::ResizeDirection::NorthWest), + (false, true, true, false) => Some(egui::viewport::ResizeDirection::NorthEast), + (true, false, false, true) => Some(egui::viewport::ResizeDirection::SouthWest), + (false, true, false, true) => Some(egui::viewport::ResizeDirection::SouthEast), + (true, false, _, _) => Some(egui::viewport::ResizeDirection::West), + (false, true, _, _) => Some(egui::viewport::ResizeDirection::East), + (_, _, true, false) => Some(egui::viewport::ResizeDirection::North), + (_, _, false, true) => Some(egui::viewport::ResizeDirection::South), + _ => None, + }; + if let Some(dir) = dir + && ctx.input(|i| i.pointer.primary_pressed()) + && !controls.contains(pos) + { + let direction = match dir { + egui::ResizeDirection::North => "north", + egui::ResizeDirection::South => "south", + egui::ResizeDirection::East => "east", + egui::ResizeDirection::West => "west", + egui::ResizeDirection::NorthEast => "northeast", + egui::ResizeDirection::NorthWest => "northwest", + egui::ResizeDirection::SouthEast => "southeast", + egui::ResizeDirection::SouthWest => "southwest", + }; + app.run_or_alert("ui.window.beginResize", json!({"direction": direction})); + } + app.flush_viewport_commands(ctx); +} + #[cfg(test)] mod title_bar_tests { use super::*;