Merge pull request #41 from storytold/pr/custom-titlebar-dragon-icon
UI: custom title bar on Windows and Linux; the dragon icon as the brand mark
This commit is contained in:
commit
6a9b658443
|
|
@ -4330,6 +4330,7 @@ version = "0.1.1"
|
|||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ fn app_icon() -> egui::IconData {
|
|||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
|
|
@ -148,6 +152,7 @@ fn main() -> eframe::Result {
|
|||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
|
|
@ -165,6 +170,7 @@ fn main() -> eframe::Result {
|
|||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ artwork. Licence: see `LICENSE.txt`.
|
|||
| `vectorcraft-macos-512.png` | tile with Apple margin, 512 px | runtime Dock icon (`apps/vectorcraft/src/main.rs`) |
|
||||
| `vectorcraft.icns` | macOS icon set, 16 to 1024 px | `cargo xtask bundle` (`CFBundleIconFile`) |
|
||||
| `vectorcraft.ico` | Windows icon, 16 to 256 px | `apps/vectorcraft/build.rs` (embedded in the `.exe`) |
|
||||
| `hicolor/<size>/apps/ai.storyteller.vectorcraft.png` | Linux theme icons, 16 to 512 px; the 256 one is also the runtime icon on Windows and Linux | `packaging/linux/ai.storyteller.vectorcraft.desktop` |
|
||||
| `hicolor/<size>/apps/ai.storyteller.vectorcraft.png` | Linux theme icons, 16 to 512 px; the 256 one is also the runtime icon on Windows and Linux, the 128 one the brand mark in the app bar and About box (`crates/ui-egui/src/brand.rs`) | `packaging/linux/ai.storyteller.vectorcraft.desktop` |
|
||||
| `hicolor/scalable/apps/ai.storyteller.vectorcraft.svg` | Linux scalable icon | as above |
|
||||
|
||||
## How it reaches each OS
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ vectorcraft-pdf = { workspace = true }
|
|||
vectorcraft-format = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,52 +1,80 @@
|
|||
//! The VectorCraft brand mark (original artwork, drawn in code): a warm diagonal-gradient rounded
|
||||
//! square with a white pen nib — the same design as the app icon rendered by `cargo xtask bundle`.
|
||||
//! The VectorCraft brand mark: the app icon (the engraved dragon, `assets/app-icon/`, see its
|
||||
//! README). The PNG carries the icon's rounded corners; it is decoded once per context into a
|
||||
//! mipmapped texture, so it stays crisp from the 22 pt app-bar mark to the About box.
|
||||
|
||||
use egui::epaint::{Mesh, Vertex, WHITE_UV};
|
||||
use egui::{Color32, Painter, Pos2, Rect, Shape, pos2};
|
||||
use egui::{Color32, Context, Id, Rect, TextureHandle, TextureOptions, Ui, pos2};
|
||||
|
||||
const TOP: Color32 = Color32::from_rgb(0xff, 0x9a, 0x3c);
|
||||
const BOTTOM: Color32 = Color32::from_rgb(0xd4, 0x14, 0x5a);
|
||||
/// 128 px: sharp at 44 pt on a 2× display, and the mipmaps keep 22 pt clean.
|
||||
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.vectorcraft.png");
|
||||
|
||||
fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
|
||||
let m = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
|
||||
Color32::from_rgb(m(a.r(), b.r()), m(a.g(), b.g()), m(a.b(), b.b()))
|
||||
/// The icon's pixels (one transparent pixel if it couldn't be decoded, which a test rules out).
|
||||
fn decode() -> egui::ColorImage {
|
||||
match image::load_from_memory_with_format(ICON_PNG, image::ImageFormat::Png) {
|
||||
Ok(img) => {
|
||||
let img = img.to_rgba8();
|
||||
egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw())
|
||||
}
|
||||
Err(_) => egui::ColorImage::filled([1, 1], Color32::TRANSPARENT),
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon texture, uploaded on first use and kept in the context.
|
||||
fn texture(ctx: &Context) -> TextureHandle {
|
||||
let id = Id::new("vectorcraft-brand-mark");
|
||||
if let Some(tex) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
|
||||
return tex;
|
||||
}
|
||||
let options = TextureOptions { mipmap_mode: Some(egui::TextureFilter::Linear), ..TextureOptions::LINEAR };
|
||||
let tex = ctx.load_texture("vectorcraft-brand-mark", decode(), options);
|
||||
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
|
||||
tex
|
||||
}
|
||||
|
||||
/// Paint the mark into `r` (square).
|
||||
pub fn paint_mark(p: &Painter, r: Rect) {
|
||||
let rad = r.width() * 0.22;
|
||||
// Rounded-square outline as a fan so each vertex gets its diagonal-gradient colour.
|
||||
let mut outline = vec![];
|
||||
for (c, a0) in [
|
||||
(pos2(r.max.x - rad, r.min.y + rad), -90.0f32),
|
||||
(pos2(r.max.x - rad, r.max.y - rad), 0.0),
|
||||
(pos2(r.min.x + rad, r.max.y - rad), 90.0),
|
||||
(pos2(r.min.x + rad, r.min.y + rad), 180.0),
|
||||
] {
|
||||
for i in 0..=6 {
|
||||
let a = (a0 + 90.0 * i as f32 / 6.0).to_radians();
|
||||
outline.push(c + egui::vec2(a.cos(), a.sin()) * rad);
|
||||
}
|
||||
}
|
||||
let colour = |q: Pos2| {
|
||||
let t = ((q.x - r.min.x) + (q.y - r.min.y)) / (r.width() + r.height());
|
||||
lerp(TOP, BOTTOM, t.clamp(0.0, 1.0))
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
mesh.vertices.push(Vertex { pos: r.center(), uv: WHITE_UV, color: colour(r.center()) });
|
||||
for q in &outline {
|
||||
mesh.vertices.push(Vertex { pos: *q, uv: WHITE_UV, color: colour(*q) });
|
||||
}
|
||||
let n = outline.len() as u32;
|
||||
for i in 0..n {
|
||||
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % n);
|
||||
}
|
||||
p.add(Shape::mesh(mesh));
|
||||
// Pen nib (same proportions as the app icon, 1024-unit design space).
|
||||
let s = r.width() / 1024.0;
|
||||
let at = |x: f32, y: f32| pos2(r.min.x + x * s, r.min.y + y * s);
|
||||
let nib = vec![at(512.0, 770.0), at(352.0, 540.0), at(417.0, 290.0), at(607.0, 290.0), at(672.0, 540.0)];
|
||||
p.add(Shape::convex_polygon(nib, Color32::WHITE, egui::Stroke::NONE));
|
||||
p.circle_filled(at(512.0, 490.0), 40.0 * s, colour(at(512.0, 490.0)));
|
||||
p.line_segment([at(512.0, 525.0), at(512.0, 765.0)], egui::Stroke::new((18.0 * s).max(1.0), colour(at(512.0, 650.0))));
|
||||
pub fn paint_mark(ui: &Ui, r: Rect) {
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
ui.painter().image(texture(ui.ctx()).id(), r, uv, Color32::WHITE);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
#[test]
|
||||
fn the_icon_decodes_to_a_rounded_128_px_tile() {
|
||||
let img = decode();
|
||||
assert_eq!(img.size, [128, 128]);
|
||||
// Rounded corners: the corner pixel is transparent, the centre opaque.
|
||||
assert_eq!(img.pixels[0].a(), 0);
|
||||
assert_eq!(img.pixels[64 * 128 + 64].a(), 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_bar_paints_the_icon_from_one_cached_texture() {
|
||||
let ctx = Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let mut frame = || {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::chrome::app_bar(&mut app, ui));
|
||||
let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count();
|
||||
out.textures_delta.clear();
|
||||
let marks: Vec<Rect> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Mesh(m) if m.texture_id == texture(&ctx).id() => Some(m.calc_bounds()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(uploads, marks)
|
||||
};
|
||||
let (uploads, marks) = frame();
|
||||
assert_eq!(uploads, 1);
|
||||
assert_eq!(marks.len(), 1);
|
||||
assert_eq!(marks[0].size(), egui::vec2(22.0, 22.0));
|
||||
// Later frames reuse the texture.
|
||||
let (uploads, marks) = frame();
|
||||
assert_eq!((uploads, marks.len()), (0, 1));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,34 +8,54 @@ use crate::panels::stroke as stroke_panel;
|
|||
use crate::state::{ZOOM_STOPS, zoom_label};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{self, paint_chip};
|
||||
use crate::{VectorcraftApp, icons, menus};
|
||||
use crate::{VectorcraftApp, icons, menus, titlebar};
|
||||
|
||||
/// 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.
|
||||
pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let custom = app.custom_titlebar;
|
||||
let left = if app.integrated_titlebar { 78 } else { 8 };
|
||||
egui::Panel::top("app_bar").exact_size(44.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
|
||||
let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: if custom { 0 } else { 14 }, top: 0, bottom: 0 });
|
||||
let bar = egui::Panel::top("app_bar").exact_size(44.0).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
|
||||
if custom {
|
||||
titlebar::drag_area(ui, ui.max_rect());
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
// Brand mark (our own artwork, matches the app icon).
|
||||
// Brand mark: the app icon.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
|
||||
crate::brand::paint_mark(ui.painter(), r);
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.add_space(4.0);
|
||||
if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"})));
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
if app.native_menu {
|
||||
let menus_end = if app.native_menu {
|
||||
let full = ui.max_rect();
|
||||
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
|
||||
ui.cursor().min.x
|
||||
} else {
|
||||
menus::menu_bar(app, ui);
|
||||
}
|
||||
menus::menu_bar(app, ui)
|
||||
};
|
||||
// The right-side group fills the space after the menus from the right; when it runs
|
||||
// short, Discord goes first (it is also under Help), then the search box becomes an
|
||||
// icon, then the workspace switcher narrows.
|
||||
let full = ui.max_rect();
|
||||
let right = egui::Rect::from_min_max(egui::pos2(full.right() - 440.0, full.top()), full.right_bottom());
|
||||
let right_edge = if custom { full.right() - titlebar::WIDTH - 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()));
|
||||
let room = right.width();
|
||||
let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text);
|
||||
let ws_w = (ws.size().x + 36.0).clamp(112.0, 190.0);
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let with_search = ws_w + 8.0 + gap + 200.0;
|
||||
let search_full = room >= with_search;
|
||||
let discord = room >= with_search + 10.0 + gap + crate::community::discord_width(ui, false);
|
||||
let ws_w = if search_full { ws_w } else { ws_w.min(room - 8.0 - gap - 24.0).max(64.0) };
|
||||
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let ui = &mut rui;
|
||||
// Workspace switcher: shows the current workspace, opens Window → Workspace.
|
||||
let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text);
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2((ws.size().x + 36.0).clamp(112.0, 190.0), 24.0), Sense::click());
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
|
||||
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
|
|
@ -43,19 +63,29 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
|
||||
ui.add_space(8.0);
|
||||
// Search box → command palette.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(12), t.input);
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside);
|
||||
icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim);
|
||||
ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let open_palette = if search_full {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(12), t.input);
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside);
|
||||
icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim);
|
||||
ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim);
|
||||
resp.clicked()
|
||||
} else {
|
||||
widgets::icon_button(ui, "search", "Search commands and tools", false, 24.0).clicked()
|
||||
};
|
||||
if open_palette {
|
||||
app.ui.palette_open = true;
|
||||
app.ui.palette_query.clear();
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
crate::community::discord_button(app, ui, false);
|
||||
if discord {
|
||||
ui.add_space(10.0);
|
||||
crate::community::discord_button(app, ui, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
if custom {
|
||||
titlebar::caption_buttons(app, ui, bar.response.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Control bar chip that opens something: `draw` paints the chip, a chevron follows it.
|
||||
|
|
|
|||
|
|
@ -10,12 +10,23 @@ use crate::{VectorcraftApp, icons};
|
|||
/// Discord's brand colour (a colour, not a logo).
|
||||
const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xF2);
|
||||
|
||||
/// A Discord pill button: compact for the header, large for About / Home.
|
||||
pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egui::Response {
|
||||
/// The Discord button's label, height and width.
|
||||
fn discord_layout(ui: &Ui, large: bool) -> (std::sync::Arc<egui::Galley>, f32, f32) {
|
||||
let (h, font, label) = if large { (36.0, theme::semibold(14.0), "Join our Discord") } else { (24.0, theme::semibold(12.0), "Discord") };
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), font, Color32::WHITE);
|
||||
let w = galley.size().x + h * 0.55 + h * 0.9;
|
||||
(galley, h, w)
|
||||
}
|
||||
|
||||
/// The Discord button's width (the app bar drops it when space runs out).
|
||||
pub fn discord_width(ui: &Ui, large: bool) -> f32 {
|
||||
discord_layout(ui, large).2
|
||||
}
|
||||
|
||||
/// A Discord pill button: compact for the header, large for About / Home.
|
||||
pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egui::Response {
|
||||
let (galley, h, w) = discord_layout(ui, large);
|
||||
let icon = h * 0.55;
|
||||
let w = galley.size().x + icon + h * 0.9;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click());
|
||||
let fill = if resp.hovered() { DISCORD.gamma_multiply(0.85) } else { DISCORD };
|
||||
ui.painter().rect_filled(r, CornerRadius::same((h / 2.0) as u8), fill);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
ui.set_width(380.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover());
|
||||
crate::brand::paint_mark(ui.painter(), r);
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0)));
|
||||
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub mod shortcut_editor;
|
|||
pub mod shortcuts;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
pub mod unsaved;
|
||||
pub mod widgets;
|
||||
|
|
@ -146,6 +147,9 @@ pub struct VectorcraftApp {
|
|||
pub(crate) clipboard_in: Option<String>,
|
||||
/// A URL to open through egui next frame (when the host has no `open_url` service).
|
||||
pending_url: Option<String>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl VectorcraftApp {
|
||||
|
|
@ -184,6 +188,7 @@ impl VectorcraftApp {
|
|||
last_time: 0.0,
|
||||
canvas_rect: None,
|
||||
hover_doc: None,
|
||||
custom_titlebar: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -520,6 +525,9 @@ impl VectorcraftApp {
|
|||
panels::library_panel::show_window(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
palette::show(self, &ctx);
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
let _ = json!(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1396,23 +1396,28 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
}
|
||||
|
||||
/// Render the menu bar.
|
||||
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
|
||||
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let tree = menu_tree();
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
for (i, (title, items)) in tree.iter().enumerate() {
|
||||
let text = if i == 0 {
|
||||
egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text)
|
||||
} else {
|
||||
egui::RichText::new(*title).size(13.0).color(t.text)
|
||||
};
|
||||
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
|
||||
}
|
||||
});
|
||||
let end = egui::MenuBar::new()
|
||||
.ui(ui, |ui| {
|
||||
for (i, (title, items)) in tree.iter().enumerate() {
|
||||
let text = if i == 0 {
|
||||
egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text)
|
||||
} else {
|
||||
egui::RichText::new(*title).size(13.0).color(t.text)
|
||||
};
|
||||
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
|
||||
}
|
||||
ui.cursor().min.x
|
||||
})
|
||||
.inner;
|
||||
if let Some((id, p)) = clicked {
|
||||
invoke(app, &id, p);
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ pub struct Tokens {
|
|||
pub flyout_active: Color32,
|
||||
/// Hint-bar / tab-bar heights etc. scale with this base font size.
|
||||
pub font_size: f32,
|
||||
/// Window Close button (custom title bar) when hovered, and its glyph there.
|
||||
pub caption_close: Color32,
|
||||
pub caption_close_text: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
|
|
@ -116,6 +119,8 @@ impl Tokens {
|
|||
anchor_selected: hex(0x3d82ff),
|
||||
flyout_active: hex(0x54b2f7),
|
||||
font_size: 13.0,
|
||||
caption_close: hex(0xc42b1c),
|
||||
caption_close_text: Color32::WHITE,
|
||||
};
|
||||
match b {
|
||||
Brightness::Dark => base,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
//! 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.
|
||||
|
||||
use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// One caption button: as wide as the native Windows ones.
|
||||
pub const BUTTON_WIDTH: f32 = 46.0;
|
||||
/// The three caption buttons together.
|
||||
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
|
||||
/// Resize zone thickness along an edge, and the side of a corner zone.
|
||||
const EDGE: f32 = 5.0;
|
||||
const CORNER: f32 = 12.0;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Caption {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Left to right.
|
||||
const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
|
||||
|
||||
/// Caption button ids (stable, so tests and agents can find them).
|
||||
fn caption_id(c: Caption) -> Id {
|
||||
Id::new(("titlebar-caption", c as u8))
|
||||
}
|
||||
|
||||
fn maximized(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
/// The bar's background: a press-and-drag on empty space moves the window, a double-click toggles
|
||||
/// maximize. Call it before laying out the bar so its widgets, registered later, take the clicks.
|
||||
pub fn drag_area(ui: &Ui, rect: Rect) {
|
||||
let ctx = ui.ctx();
|
||||
let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag());
|
||||
// egui hands a drag that starts on a click-only widget (a menu title) to the drag-sensing
|
||||
// background below it, so remember whether the press itself landed on bare background.
|
||||
if ctx.input(|i| i.pointer.any_pressed()) {
|
||||
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
|
||||
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
toggle_maximize(ctx);
|
||||
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 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()) {
|
||||
(true, _) if close => t.caption_close.gamma_multiply(0.8),
|
||||
(_, true) if close => t.caption_close,
|
||||
(true, _) => t.tool_active,
|
||||
(_, true) => t.hover,
|
||||
_ => egui::Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon };
|
||||
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
|
||||
let tip = match c {
|
||||
Caption::Minimize => "Minimize",
|
||||
Caption::Maximize if max => "Restore",
|
||||
Caption::Maximize => "Maximize",
|
||||
Caption::Close => "Close",
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
clicked = Some(c);
|
||||
}
|
||||
}
|
||||
match clicked {
|
||||
Some(Caption::Minimize) => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)),
|
||||
Some(Caption::Maximize) => toggle_maximize(ui.ctx()),
|
||||
Some(Caption::Close) => {
|
||||
app.run("app.quit", json!({})).ok();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 pt line glyphs of the caption buttons.
|
||||
fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
|
||||
let p = ui.painter();
|
||||
let g = Rect::from_center_size(at, vec2(10.0, 10.0));
|
||||
match c {
|
||||
Caption::Minimize => {
|
||||
p.line_segment([g.left_center(), g.right_center()], s);
|
||||
}
|
||||
// Restore: a front square with the corner of the one behind it.
|
||||
Caption::Maximize if maximized => {
|
||||
let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0));
|
||||
p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle);
|
||||
p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s);
|
||||
}
|
||||
Caption::Maximize => {
|
||||
p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle);
|
||||
}
|
||||
Caption::Close => {
|
||||
p.line_segment([g.left_top(), g.right_bottom()], s);
|
||||
p.line_segment([g.right_top(), g.left_bottom()], s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners
|
||||
/// come last so they win where they overlap the edges.
|
||||
fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] {
|
||||
use ResizeDirection::*;
|
||||
let (e, c) = (EDGE, CORNER);
|
||||
[
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
|
||||
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
|
||||
]
|
||||
}
|
||||
|
||||
/// Invisible resize zones along the window's edges, above everything else; a press in one starts
|
||||
/// an OS resize in that direction. None while the window is maximized or fullscreen.
|
||||
pub fn resize_zones(ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let w = ctx.content_rect();
|
||||
let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize"));
|
||||
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
|
||||
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
|
||||
let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, Pos2, RawInput, ViewportId};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// A headless window `width` pt wide showing the app bar (as the title bar) and the resize zones.
|
||||
struct Win {
|
||||
ctx: egui::Context,
|
||||
app: VectorcraftApp,
|
||||
width: f32,
|
||||
time: f64,
|
||||
maximized: bool,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
fn new(width: f32) -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.custom_titlebar = true;
|
||||
Self { ctx, app, width, time: 0.0, maximized: false }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
|
||||
self.time += 0.05;
|
||||
let mut input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 600.0))),
|
||||
time: Some(self.time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
|
||||
v.maximized = Some(self.maximized);
|
||||
}
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
crate::chrome::app_bar(app, ui);
|
||||
resize_zones(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn texts(out: &egui::FullOutput) -> Vec<(String, Rect)> {
|
||||
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
fn button(p: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
impl Win {
|
||||
/// Frames for a pointer gesture: move to `path[0]`, press, move along `path`, release. The
|
||||
/// viewport commands the frames sent.
|
||||
fn gesture(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
|
||||
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
|
||||
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
|
||||
events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
|
||||
self.gesture(&[p])
|
||||
}
|
||||
|
||||
fn caption(&self, c: Caption) -> Rect {
|
||||
self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING)
|
||||
}
|
||||
|
||||
/// The bar's clickable widgets other than its background and the caption buttons.
|
||||
fn bar_widgets(&self) -> Vec<Rect> {
|
||||
let skip: Vec<Id> = std::iter::once(Id::new("titlebar-drag")).chain(CAPTIONS.map(caption_id)).collect();
|
||||
self.ctx.viewport(|v| {
|
||||
v.prev_pass
|
||||
.widgets
|
||||
.layers()
|
||||
.filter(|(l, _)| l.order != Order::Foreground)
|
||||
.flat_map(|(_, ws)| ws.iter())
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 44.0 && !skip.contains(&w.id))
|
||||
.map(|w| w.rect)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_buttons_take_the_right_end_and_nothing_overlaps_down_to_800_pt() {
|
||||
for width in [800.0, 1100.0, 1440.0] {
|
||||
let mut w = Win::new(width);
|
||||
w.frame(vec![]);
|
||||
let out = w.frame(vec![]);
|
||||
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
|
||||
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
|
||||
assert!(close.height() >= 42.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
|
||||
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
|
||||
let widgets = w.bar_widgets();
|
||||
assert!(widgets.len() >= 12, "home, nine menus, workspace switcher, search: {widgets:?}");
|
||||
for (i, a) in widgets.iter().enumerate() {
|
||||
assert!(a.right() <= min.left() - 8.0, "{a:?} runs into the caption buttons at {width}");
|
||||
for b in &widgets[i + 1..] {
|
||||
assert!(!a.shrink(0.5).intersects(b.shrink(0.5)), "{a:?} overlaps {b:?} at {width}");
|
||||
}
|
||||
}
|
||||
// Wide windows show the whole group; narrow ones drop Discord and shorten the search.
|
||||
let texts: Vec<String> = texts(&out).into_iter().map(|(t, _)| t).collect();
|
||||
let has = |s: &str| texts.iter().any(|t| t == s);
|
||||
assert_eq!((has("Discord"), has("Search commands and tools")), (width > 1000.0, width > 1000.0), "{width}: {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_runs_the_quit_flow_instead_of_closing_the_window() {
|
||||
let mut w = Win::new(1000.0);
|
||||
w.app.run("file.new", serde_json::json!({"width": 100, "height": 100})).unwrap();
|
||||
w.app.run("shape.rectangle", serde_json::json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
w.frame(vec![]);
|
||||
let close = w.caption(Caption::Close).center();
|
||||
let cmds = w.click(close);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::Close)), "{cmds:?}");
|
||||
let dialog = w.app.ui.dialog.as_ref().map(|d| d.kind.clone());
|
||||
assert_eq!(dialog.as_deref(), Some(crate::unsaved::KIND), "a modified document asks first");
|
||||
assert_ne!(w.app.ui.status, "quit");
|
||||
// Nothing to save: the host is told to quit.
|
||||
w.app.ui.dialog = None;
|
||||
w.app.session.doc_mut().unwrap().mark_saved();
|
||||
w.click(close);
|
||||
assert_eq!(w.app.ui.status, "quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimize_and_maximize_follow_the_window_state() {
|
||||
let mut w = Win::new(1000.0);
|
||||
w.frame(vec![]);
|
||||
let (min, max) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).center());
|
||||
assert!(w.click(min).contains(&ViewportCommand::Minimized(true)));
|
||||
assert!(w.click(max).contains(&ViewportCommand::Maximized(true)));
|
||||
w.maximized = true;
|
||||
assert!(w.click(max).contains(&ViewportCommand::Maximized(false)), "Restore");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bar_space_drags_and_double_click_maximizes() {
|
||||
let mut w = Win::new(1440.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(700.0, 22.0);
|
||||
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
|
||||
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
// Double-click toggles maximize.
|
||||
let mut cmds = w.click(empty);
|
||||
cmds.extend(w.click(empty));
|
||||
assert!(cmds.contains(&ViewportCommand::Maximized(true)), "{cmds:?}");
|
||||
w.maximized = true;
|
||||
w.time += 1.0;
|
||||
let mut cmds = w.click(empty);
|
||||
cmds.extend(w.click(empty));
|
||||
assert!(cmds.contains(&ViewportCommand::Maximized(false)), "{cmds:?}");
|
||||
// Widgets keep their clicks: dragging from a menu title doesn't move the window.
|
||||
let file = texts(&w.frame(vec![])).into_iter().find(|(t, _)| t == "File").map(|(_, r)| r.center());
|
||||
let file = file.expect("the File menu title");
|
||||
let cmds = w.gesture(&[file, file + vec2(12.0, 4.0), file + vec2(30.0, 8.0)]);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_zones_resize_unless_maximized() {
|
||||
use ResizeDirection::*;
|
||||
let mut w = Win::new(1000.0);
|
||||
w.frame(vec![]);
|
||||
for (p, dir) in [
|
||||
(pos2(1.0, 300.0), West),
|
||||
(pos2(998.0, 300.0), East),
|
||||
(pos2(500.0, 1.0), North),
|
||||
(pos2(500.0, 598.0), South),
|
||||
(pos2(2.0, 2.0), NorthWest),
|
||||
(pos2(997.0, 3.0), NorthEast),
|
||||
(pos2(3.0, 597.0), SouthWest),
|
||||
(pos2(998.0, 598.0), SouthEast),
|
||||
] {
|
||||
let cmds = w.gesture(&[p, p + vec2(6.0, 6.0)]);
|
||||
assert_eq!(cmds, vec![ViewportCommand::BeginResize(dir)], "at {p:?}");
|
||||
}
|
||||
let out = w.frame(vec![Event::PointerMoved(pos2(1.0, 300.0))]);
|
||||
assert_eq!(out.platform_output.cursor_icon, CursorIcon::ResizeHorizontal);
|
||||
// Maximized: no zones, and the window's top-right corner is Close.
|
||||
w.maximized = true;
|
||||
let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
|
||||
assert!(w.click(pos2(999.5, 0.5)).is_empty());
|
||||
assert_eq!(w.app.ui.status, "quit");
|
||||
}
|
||||
}
|
||||
|
|
@ -94,3 +94,9 @@ folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.s
|
|||
Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field
|
||||
`color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference
|
||||
`patternTileEdgeColor` (`prefs.set`), the colour pattern editing mode draws the tile edge and the swatch bounds in.
|
||||
|
||||
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. macOS and the web
|
||||
build are unchanged.
|
||||
|
|
|
|||
Loading…
Reference in New Issue