Linux: the title bar carries the window controls, as in Excel (#192)

This commit is contained in:
gyurix authored and GitHub committed 2026-11-14 16:48:20 +01:00
1 parent 40783ab184
commit 494b830240
25 files changed
+259 -2

No files matched your search

+5 -1
View File
@@ -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);
}
+11
View File
@@ -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)]
+20
View File
@@ -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<Result<Json, String>> {
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)
+4
View File
@@ -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}"),
(
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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" => "Κενό βιβλίο εργασίας",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -364,6 +364,9 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Save", "上書き保存"),
("Save As", "名前を付けて保存"),
("Export", "エクスポート"),
("Minimize", "最小化"),
("Maximize", "最大化"),
("Restore Down", "元のサイズに戻す"),
("Close", "閉じる"),
("Options", "オプション"),
("Blank workbook", "空白のブック"),
+3
View File
@@ -370,6 +370,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save" => "저장",
"Save As" => "다른 이름으로 저장",
"Export" => "내보내기",
"Minimize" => "최소화",
"Maximize" => "최대화",
"Restore Down" => "이전 크기로 복원",
"Close" => "닫기",
"Options" => "옵션",
"Account" => "계정",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -372,6 +372,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save" => "Сохранить",
"Save As" => "Сохранить как",
"Export" => "Экспорт",
"Minimize" => "Свернуть",
"Maximize" => "Развернуть",
"Restore Down" => "Восстановить",
"Close" => "Закрыть",
"Options" => "Параметры",
"Account" => "Учетная запись",
+3
View File
@@ -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",
+3
View File
@@ -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ı",
+3
View File
@@ -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" => "Порожня книга",
+3
View File
@@ -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",
+3
View File
@@ -368,6 +368,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save" => "保存",
"Save As" => "另存为",
"Export" => "导出",
"Minimize" => "最小化",
"Maximize" => "最大化",
"Restore Down" => "向下还原",
"Close" => "关闭",
"Options" => "选项",
"Account" => "账户",
+47
View File
@@ -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<egui::ResizeDirection> {
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<egui::ViewportCommand>,
/// 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());
}
}
+1 -1
View File
@@ -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;
+117
View File
@@ -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::*;