Merge pull request #7317 from dotCooCoo/fix/app-bar-button-layout

Title bar: the app bar places its window buttons by the desktop's button layout
This commit is contained in:
BFlat
2026-11-14 07:34:18 +07:00
committed by GitHub
9 changed files with 181 additions and 33 deletions
+3 -2
View File
@@ -107,7 +107,7 @@ use titlebar::opted_into_system_title_bar;
#[cfg(windows)]
use titlebar::set_taskbar_icon;
#[cfg(all(test, feature = "wgpu"))]
use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame, system_title_bar_applies};
use titlebar::{app_draws_frame, caption_layout_from, opted_into_app_title_bar, start_frame, system_title_bar_applies};
use titlebar::{custom_titlebar, native_options, titlebar_from_env};
struct App {
@@ -367,7 +367,7 @@ fn main() -> std::process::ExitCode {
};
#[cfg(feature = "wgpu")]
let crash = left_out.crash.clone();
let custom_titlebar = custom_titlebar(saved.as_ref());
let (custom_titlebar, caption_layout) = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
@@ -593,6 +593,7 @@ fn main() -> std::process::ExitCode {
}
app.titlebar_from_env = titlebar_from_env();
app.custom_titlebar = custom_titlebar;
app.caption_layout = caption_layout;
app.desktop_frame = !custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
+33 -6
View File
@@ -220,8 +220,10 @@ fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() {
}
/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame
/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame
/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference.
/// with the default window buttons, an app frame the desktop left to the app keeps its own buttons,
/// one that `VECTORCRAFT_TITLEBAR=app` forced or the preference kept through XWayland gets the
/// default buttons, and the desktop frame stays
/// where the app frame is not drawn. System Title Bar takes precedence over the preference.
#[test]
fn the_start_frame_applies_the_app_title_bar_preference() {
use craft_window::{Button, Frame, Layout};
@@ -233,9 +235,10 @@ fn the_start_frame_applies_the_app_title_bar_preference() {
((Frame::Desktop, None, true, true), Frame::App(Layout::default())),
((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())),
((Frame::App(gnome.clone()), None, true, false), Frame::Desktop),
((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())),
((Frame::App(gnome.clone()), None, true, true), Frame::App(Layout::default())),
((Frame::Desktop, Some(false), false, true), Frame::Desktop),
((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())),
((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(Layout::default())),
((Frame::App(gnome.clone()), Some(true), false, false), Frame::App(Layout::default())),
];
for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases {
let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}");
@@ -246,6 +249,30 @@ fn the_start_frame_applies_the_app_title_bar_preference() {
assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference");
}
/// The app bar's caption buttons follow the window-button layout of the app frame on Linux and the
/// BSDs (Close alone at the right, or all three at the left with Close outermost); the desktop frame,
/// macOS and Windows keep all three at the right.
#[test]
fn the_caption_buttons_follow_the_frame_layout() {
use craft_window::{Button, Frame, Layout};
use vectorcraft_ui_egui::titlebar::{Caption, CaptionLayout};
let close_only = Layout::parse("appmenu:close");
let at_left = Layout { left: vec![Button::Close, Button::Minimize, Button::Maximize], right: vec![] };
let split = Layout { left: vec![Button::Close], right: vec![Button::Minimize, Button::Maximize] };
let probed = [
(close_only, CaptionLayout { left: vec![], right: vec![Caption::Close] }),
(at_left, CaptionLayout { left: vec![Caption::Close, Caption::Minimize, Caption::Maximize], right: vec![] }),
(split, CaptionLayout { left: vec![Caption::Close], right: vec![Caption::Minimize, Caption::Maximize] }),
];
for (layout, expected) in probed {
let shown = super::caption_layout_from(&Frame::App(layout.clone()));
let expected = if cfg!(any(target_os = "macos", target_os = "windows")) { CaptionLayout::default() } else { expected };
assert_eq!(shown, expected, "{layout:?}");
}
assert_eq!(super::caption_layout_from(&Frame::Desktop), CaptionLayout::default());
assert_eq!(CaptionLayout::default().right, vec![Caption::Minimize, Caption::Maximize, Caption::Close]);
}
/// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and
/// on. The saved preference still decides while the variable is unset.
#[test]
@@ -303,7 +330,7 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] {
let custom = super::custom_titlebar(saved);
let (custom, _) = super::custom_titlebar(saved);
let expected = match value.as_str() {
_ if not_read => !cfg!(target_os = "macos") && !opted_into_system,
"app" => true,
@@ -312,7 +339,7 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
// `craft-window` probe decides, the same with System Title Bar off as with no saved
// preferences.
_ if opted_into_system => false,
_ => super::custom_titlebar(None),
_ => super::custom_titlebar(None).0,
};
assert_eq!(custom, expected, "{value}, preference {opted_into_system}");
assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}");
+36 -8
View File
@@ -68,18 +68,45 @@ pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option<bool>, forced
}
/// The frame this start uses, before the System Title Bar preference is applied
/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed
/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default
/// layout, since `custom_titlebar_from` reads only which frame it is.
/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. An app frame
/// the desktop left to the app (no `VECTORCRAFT_TITLEBAR` override, not forced onto XWayland) keeps
/// its window-button layout. An app frame turned on by `VECTORCRAFT_TITLEBAR=app` or by VectorCraft
/// Title Bar on Every Desktop gets the default layout: on those desktops the `button-layout` key that `craft-window` reads can
/// hold its schema default (`appmenu:close`) rather than the desktop's own buttons.
pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option<bool>, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame {
let probed_app = matches!(probed, craft_window::Frame::App(_));
match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) {
(true, frame @ craft_window::Frame::App(_)) => frame,
(true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()),
(true, frame @ craft_window::Frame::App(_)) if overridden.is_none() && !forced_to_x11 => frame,
(true, _) => craft_window::Frame::App(craft_window::Layout::default()),
(false, _) => craft_window::Frame::Desktop,
}
}
/// Where the app bar puts its caption buttons for `frame`: on Linux and the BSDs, the window-button
/// layout of an app frame, which `craft-window` reads from the desktop (the `button-layout` key of
/// `org.gnome.desktop.wm.preferences`); otherwise all three at the right. Each list is copied in the
/// key's order, first item leftmost, so `close,minimize,maximize:` puts Close at the left edge.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn caption_layout_from(frame: &craft_window::Frame) -> vectorcraft_ui_egui::titlebar::CaptionLayout {
use vectorcraft_ui_egui::titlebar::{Caption, CaptionLayout};
let caption = |b: &craft_window::Button| match b {
craft_window::Button::Minimize => Caption::Minimize,
craft_window::Button::Maximize => Caption::Maximize,
craft_window::Button::Close => Caption::Close,
};
match frame.layout() {
Some(layout) => CaptionLayout { left: layout.left.iter().map(caption).collect(), right: layout.right.iter().map(caption).collect() },
None => CaptionLayout::default(),
}
}
/// macOS and Windows: all three caption buttons at the right, whatever the probe says (see
/// [`custom_titlebar_from`]).
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn caption_layout_from(_frame: &craft_window::Frame) -> vectorcraft_ui_egui::titlebar::CaptionLayout {
vectorcraft_ui_egui::titlebar::CaptionLayout::default()
}
/// The `craft-window` probe's answer for this process (its override variable is
/// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection.
fn probed_choice() -> &'static craft_window::Choice {
@@ -131,7 +158,8 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
CUSTOM_TITLEBAR && !opted_into_system
}
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// Whether this start draws its own title bar, and where its caption buttons go
/// ([`caption_layout_from`]). Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference, which does not apply while the variable is set to `app`, `system` or
/// `desktop`. Call once at startup,
@@ -142,7 +170,7 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
/// frame, the same as a real X11 session. A recognized `VECTORCRAFT_TITLEBAR` override still wins,
/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]).
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> (bool, vectorcraft_ui_egui::titlebar::CaptionLayout) {
let choice = probed_choice();
let opted_into_system = system_title_bar_applies(opted_into_system_title_bar(saved), titlebar_from_env());
// macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from`
@@ -160,7 +188,7 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> b
#[cfg(not(target_os = "linux"))]
let forced_to_x11 = false;
let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app);
custom_titlebar_from(&frame, opted_into_system)
(custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame))
}
pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
+1
View File
@@ -134,6 +134,7 @@ impl VectorcraftApp {
hover_doc: None,
titlebar_from_env: false,
custom_titlebar: false,
caption_layout: Default::default(),
desktop_frame: false,
last_window_title: String::new(),
fullscreen_for_mode: false,
+10 -3
View File
@@ -45,7 +45,8 @@ const WIDGET_H: f32 = 22.0;
/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher
/// at the right. With [`VectorcraftApp::custom_titlebar`] it is also the window's title bar
/// ([`titlebar`]): the caption buttons take the right end and the rest of the bar drags the window.
/// ([`titlebar`]): the caption buttons take the ends [`VectorcraftApp::caption_layout`] names and the
/// rest of the bar drags the window.
/// On macOS, under the system's transparent title bar, its empty space drags and zooms the window
/// too ([`titlebar::drag_area`]). With the system title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and
/// title, so the in-app bar shows Home, menus and workspace controls only: no brand mark.
@@ -54,7 +55,10 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let custom = app.custom_titlebar;
let system = system_title_bar(app);
let left = app.titlebar_inset.clamp(8.0, f32::from(i8::MAX)) as i8;
let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: if custom { 0 } else { 14 }, top: 0, bottom: 0 });
// The room the caption buttons take at each end of the bar.
let (captions_left, captions_right) = if custom { (app.caption_layout.left_width(), app.caption_layout.right_width()) } else { (0.0, 0.0) };
let right_margin = if captions_right > 0.0 { 0 } else { 14 };
let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: right_margin, top: 0, bottom: 0 });
let bar = egui::Panel::top("app_bar").exact_size(APP_BAR_HEIGHT).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
// macOS draws the bar under its transparent title bar, so the bar gets the title bar's
// clicks: without this a double-click there zooms nothing (#1932).
@@ -62,6 +66,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
titlebar::drag_area(ui, ui.max_rect());
}
ui.horizontal_centered(|ui| {
if captions_left > 0.0 {
ui.add_space(captions_left);
}
// With the system title bar the OS draws its own icon: no in-app brand mark.
if !system {
let (r, _) = ui.allocate_exact_size(vec2(MARK, MARK), Sense::hover());
@@ -80,7 +87,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
// Window › Learn), then the search icon (the palette stays under its shortcut), then
// the workspace switcher narrows.
let full = ui.max_rect();
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
let right_edge = if captions_right > 0.0 { full.right() - captions_right - 10.0 } else { full.right() };
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
// In Safe Mode, a label that stays in the bar whatever document is open, and opens
// Help › Safe Mode; the other controls fit in the room it leaves, and the workspace
+4
View File
@@ -227,6 +227,10 @@ pub struct VectorcraftApp {
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
pub custom_titlebar: bool,
/// Where the caption buttons go on the app bar while [`Self::custom_titlebar`] is on: the
/// desktop's window-button layout on Linux and the BSDs where the desktop leaves the frame to the
/// app, all three at the right otherwise.
pub caption_layout: titlebar::CaptionLayout,
/// The window has the desktop's own title bar: the OS or the window manager draws its frame,
/// icon and title. Set by the desktop app when it opens the window; `false` for embedders
/// without a window of their own.
+83 -11
View File
@@ -1,8 +1,9 @@
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
//! ([`VectorcraftApp::custom_titlebar`]): empty space in the app bar drags the window and a
//! double-click there maximizes it, caption buttons at the far right minimize, maximize/restore and
//! close, and thin invisible zones along the window's edges resize it. On macOS the app bar sits
//! under the system's transparent title bar and only its [`drag_area`] is used.
//! double-click there maximizes it, caption buttons at the ends that [`CaptionLayout`] names (all three
//! at the far right by default) minimize, maximize/restore and close, and thin invisible zones along
//! the window's edges resize it. On macOS the app bar sits under the system's transparent title bar
//! and only its [`drag_area`] is used.
use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use serde_json::json;
@@ -18,16 +19,44 @@ pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
const EDGE: f32 = 5.0;
const CORNER: f32 = 12.0;
#[derive(Clone, Copy, PartialEq, Debug)]
enum Caption {
/// A caption button.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Caption {
Minimize,
Maximize,
Close,
}
/// Left to right.
/// The default order, left to right.
const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
/// Which caption buttons the app bar draws at its left end and at its right end, each list left to
/// right. The desktop app sets it from the desktop's window-button layout on Linux and the BSDs
/// ([`VectorcraftApp::caption_layout`]). The default has Minimize, Maximize and Close at the right.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CaptionLayout {
pub left: Vec<Caption>,
pub right: Vec<Caption>,
}
impl Default for CaptionLayout {
fn default() -> Self {
Self { left: Vec::new(), right: CAPTIONS.to_vec() }
}
}
impl CaptionLayout {
/// The width the buttons at the left end take.
pub fn left_width(&self) -> f32 {
self.left.len() as f32 * BUTTON_WIDTH
}
/// The width the buttons at the right end take.
pub fn right_width(&self) -> f32 {
self.right.len() as f32 * BUTTON_WIDTH
}
}
/// Caption button ids (stable, so tests and agents can find them).
fn caption_id(c: Caption) -> Id {
Id::unique(("titlebar-caption", c as u8))
@@ -70,15 +99,21 @@ fn handed_to_os(ctx: &egui::Context) {
ctx.input_mut(|i| i.pointer = Default::default());
}
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the app bar's outer rect), full
/// bar height down to its bottom border, flush with the window's edge so a maximized window's
/// corner hits Close. Close runs `app.quit`, which asks about unsaved documents first.
/// The buttons of [`VectorcraftApp::caption_layout`] at the left and right ends of `bar` (the app
/// bar's outer rect), full bar height down to its bottom border, flush with the window's edges so a
/// maximized window's corner hits the outermost button. Close runs `app.quit`, which asks about
/// unsaved documents first.
pub fn caption_buttons(app: &mut VectorcraftApp, ui: &mut Ui, bar: Rect) {
let t = Tokens::get(ui.ctx());
let max = maximized(ui.ctx());
let mut clicked = None;
for (i, c) in CAPTIONS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0));
let layout = &app.caption_layout;
let left = layout.left.iter().enumerate().map(|(i, c)| (*c, bar.left() + i as f32 * BUTTON_WIDTH));
let right_start = bar.right() - layout.right_width();
let right = layout.right.iter().enumerate().map(|(i, c)| (*c, right_start + i as f32 * BUTTON_WIDTH));
let placed: Vec<(Caption, f32)> = left.chain(right).collect();
for (c, x) in placed {
let r = Rect::from_min_size(pos2(x, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0));
let resp = ui.interact(r, caption_id(c), Sense::click());
let close = c == Caption::Close;
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
@@ -296,6 +331,43 @@ mod tests {
}
}
/// The caption layout decides which buttons the bar draws and at which end, each end in its
/// listed order: Close alone at the right, all three at the left with Close outermost, and a
/// split. The bar's other controls stay between the two groups, and Close works at either end.
#[test]
fn caption_buttons_follow_the_caption_layout() {
use Caption::*;
let width = 1100.0;
for (left, right) in [(vec![], vec![Close]), (vec![Close, Minimize, Maximize], vec![]), (vec![Close], vec![Minimize, Maximize])] {
let label = format!("{left:?} : {right:?}");
let mut w = Win::new(width);
w.app.caption_layout = CaptionLayout { left: left.clone(), right: right.clone() };
w.frame(vec![]);
w.frame(vec![]);
for (i, c) in left.iter().enumerate() {
let r = w.caption(*c);
assert_eq!((r.left(), r.top(), r.width()), (i as f32 * BUTTON_WIDTH, 0.0, BUTTON_WIDTH), "{c:?} in {label}");
}
for (i, c) in right.iter().enumerate() {
let r = w.caption(*c);
let right_edge = width - (right.len() - 1 - i) as f32 * BUTTON_WIDTH;
assert_eq!((r.right(), r.top(), r.width()), (right_edge, 0.0, BUTTON_WIDTH), "{c:?} in {label}");
}
for c in CAPTIONS.into_iter().filter(|c| !left.contains(c) && !right.contains(c)) {
assert_eq!(w.caption(c), Rect::NOTHING, "{c:?} is not drawn in {label}");
}
let (start, end) = (left.len() as f32 * BUTTON_WIDTH, width - right.len() as f32 * BUTTON_WIDTH);
let widgets = w.bar_widgets();
assert!(widgets.len() >= 11, "home, eight menus, workspace switcher, search in {label}: {widgets:?}");
for r in &widgets {
assert!(r.left() >= start + 8.0 && r.right() <= end - 8.0, "{r:?} runs into the caption buttons in {label}");
}
let close = w.caption(Close).center();
w.click(close);
assert_eq!(w.app.ui.status, "quit", "Close in {label}");
}
}
#[test]
fn close_runs_the_quit_flow_instead_of_closing_the_window() {
let mut w = Win::new(1000.0);
+10 -3
View File
@@ -346,9 +346,16 @@ Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`)
A `color` with an alpha part (`#rrggbbaa` or `#rgba`) is an error, and the preference does not change.
Window title bar: on Windows and Linux the window has no OS decorations and the app bar is the title bar. Its
caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end, 46 pt each; they are window chrome,
not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for
modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User
caption buttons (Minimize, Maximize/Restore, Close) are 46 pt each. By default all three sit at the bar's right end.
On Linux and the BSDs, when the desktop leaves the window's frame to the app (a Wayland session without server-side
decorations, such as GNOME), the window is not opened through XWayland and `VECTORCRAFT_TITLEBAR` is unset, they
follow the `button-layout` key of `org.gnome.desktop.wm.preferences`, read when the app starts: each button the key
names goes to the end it is listed on, in the listed order (the first one leftmost), and the buttons it does not name
are not drawn. When `gsettings` is missing or fails, or the key names none of `minimize`, `maximize` and `close`, all
three stay at the right end. An app frame turned on by `VECTORCRAFT_TITLEBAR=app` or by VectorCraft Title Bar on Every
Desktop also keeps all three at the right end. The buttons are window chrome, not commands, so drive them with
`ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for modified documents), and empty bar
space and the 5 pt window edges move and resize the window. Preferences › User
Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back
instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar
drops its brand mark. On Linux the app bar also drops its brand mark whenever the desktop draws the window's frame, with
+1
View File
@@ -1000,6 +1000,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`).
- Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- On Linux and the BSDs, when the desktop leaves the window's frame to the app (a Wayland session without server-side decorations, such as GNOME) and the window is not opened through XWayland, the app bar's window buttons follow the `button-layout` key of `org.gnome.desktop.wm.preferences` ([schema](https://gitlab.gnome.org/GNOME/gsettings-desktop-schemas/-/blob/master/schemas/org.gnome.desktop.wm.preferences.gschema.xml.in)): Close alone at the right, all three at the left, or split between the ends, in the listed order with the first one leftmost. When the key cannot be read or names no window button, and for an app frame turned on by `VECTORCRAFT_TITLEBAR=app` or VectorCraft Title Bar on Every Desktop, all three stay at the right. Tests `titlebar::tests::caption_buttons_follow_the_caption_layout`, `tests::the_caption_buttons_follow_the_frame_layout` and `tests::the_start_frame_applies_the_app_title_bar_preference`.
- On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. Only the values `app`, `system` and `desktop` take precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not override System Title Bar.
- Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do.
- No two rows of Edit › Keyboard Shortcuts have the same English name (VectorCraft's own rule). A command that an item of the menu bar runs is listed under that item's menu path (Window › Swatch Libraries › Other Library…), a command whose id extends another command's id is listed under that command's label (Character Style Options › Reset Panel), and the rows that still share a name show their id after it, except the one row an item of the menu bar runs (File › New… and File › New… (file.new)). The search finds a row by the id it shows, and a message about a shortcut already in use names the other row with its group. Test `shortcut_editor::tests::no_two_entries_have_the_same_name`.