Custom title bar: on Windows and Linux the application bar is the window's title bar (drag to move with OS snapping, double-click to maximize or restore, minimize/maximize/close buttons, resize from the edges and corners); macOS keeps its traffic lights over the bar; the web build has no window buttons; ui.window control method (adapted from VectorCraft)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 04:13:38 +00:00
1 parent e5560e8035
commit 178fc47ed7
7 files changed
+451 -63

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ Generated-in-code assets are original and have no file to list:
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
## Vendored third-party code
+10
View File
@@ -58,6 +58,10 @@ fn services() -> Services {
}
}
/// Windows and Linux: no OS title bar; the application bar is the title bar
/// (`solvecraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
const MSAA: u16 = 4;
@@ -87,6 +91,10 @@ fn main() -> eframe::Result {
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id("ai.storyteller.solvecraft"),
depth_buffer: DEPTH_BITS,
multisampling: MSAA,
@@ -97,6 +105,8 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut app = SolveApp::new(Session::default(), services());
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
app.load_prefs(&p);
}
+5
View File
@@ -19,6 +19,7 @@
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
//! `ui.rename {text?, commit?}` finishes the rename box
//! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do
//! - `ui.resize {width, height}`, `app.quit`
use std::sync::mpsc::Sender;
@@ -252,6 +253,10 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
err("no rename in progress")
}
}
"ui.window" => match crate::titlebar::window_action(app, ctx, s("action").unwrap_or("")) {
Ok(()) => ok(json!({"custom_titlebar": app.custom_titlebar, "maximized": ctx.input(|i| i.viewport().maximized)})),
Err(e) => err(e),
},
"ui.resize" => {
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
+10
View File
@@ -27,6 +27,7 @@ mod sketch_edit_tests;
pub mod sketch_tools;
pub mod theme;
pub mod timeline;
pub mod titlebar;
pub mod toolbar;
pub mod tools;
pub mod viewport;
@@ -134,6 +135,10 @@ pub struct SolveApp {
pub last_command: Option<(String, String)>,
pub status: Option<(String, f64, bool)>,
pub quit_requested: bool,
/// The application bar is the window's title bar (Windows, Linux: no OS decorations).
pub custom_titlebar: bool,
/// macOS: the traffic lights sit over the application bar's left end.
pub integrated_titlebar: bool,
pub frame_ms: f64,
pub synthetic: Vec<egui::Event>,
control_rx: Option<Receiver<ControlRequest>>,
@@ -166,6 +171,8 @@ impl SolveApp {
last_command: None,
status: None,
quit_requested: false,
custom_titlebar: false,
integrated_titlebar: false,
frame_ms: 0.0,
synthetic: Vec::new(),
control_rx: None,
@@ -569,6 +576,9 @@ impl SolveApp {
dialogs::show(self, ui.ctx());
params_dialog::show(self, ui.ctx());
context_menu::show(self, ui.ctx());
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
palette::popup(self, ui.ctx());
self.frame_ms = now_ms() - t0;
}
+349
View File
@@ -0,0 +1,349 @@
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
//! ([`SolveApp::custom_titlebar`]): empty space in the application bar drags the window and a
//! double-click there maximizes or restores it, caption buttons at the far right minimize,
//! maximize/restore and close, and thin invisible zones along the window's edges resize it.
//! Dragging uses the OS's own move loop, so Windows snapping (to the screen edges and corners)
//! works as with a native title bar. macOS keeps its traffic lights over the bar
//! ([`SolveApp::integrated_titlebar`]); the web build has neither.
//!
//! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0).
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use crate::SolveApp;
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;
/// Room for the macOS traffic lights at the bar's left end.
pub const TRAFFIC_LIGHTS: f32 = 76.0;
/// Resize zone thickness along an edge, and the side of a corner zone.
const EDGE: f32 = 5.0;
const CORNER: f32 = 12.0;
/// The Close button's hover colour (as on Windows).
const CLOSE_HOVER: Color32 = Color32::from_rgb(196, 43, 28);
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Caption {
Minimize,
Maximize,
Close,
}
/// Left to right.
pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
/// Caption button ids (stable, so tests and agents can find them).
pub 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)));
}
/// Window actions for the control channel (`ui.window`): minimize, maximize, restore, toggle
/// (maximize or restore), close.
pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> Result<(), String> {
match action {
"minimize" => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
"maximize" => ctx.send_viewport_cmd(ViewportCommand::Maximized(true)),
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
"toggle" => toggle_maximize(ctx),
"close" => {
app.quit_requested = true;
ctx.send_viewport_cmd(ViewportCommand::Close);
}
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
}
Ok(())
}
/// 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 buttons, registered later, take
/// their 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 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 application bar's
/// rect), full bar height, flush with the window's edge so a maximized window's corner hits
/// Close.
pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) {
let t = Tokens::get();
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()));
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 => CLOSE_HOVER.gamma_multiply(0.8),
(_, true) if close => CLOSE_HOVER,
(true, _) => Color32::from_white_alpha(18),
(_, true) => Color32::from_white_alpha(30),
_ => Color32::TRANSPARENT,
};
ui.painter().rect_filled(r, 0.0, fill);
let ink = if close && resp.hovered() { Color32::WHITE } else { t.app_bar_text };
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);
}
}
let action = match clicked {
Some(Caption::Minimize) => "minimize",
Some(Caption::Maximize) => "toggle",
Some(Caption::Close) => "close",
None => return,
};
let _ = window_action(app, &ui.ctx().clone(), action);
}
/// 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 solvecraft_engine::Session;
/// A headless window `width` pt wide showing the application bar (as the title bar) and the
/// resize zones.
struct Win {
ctx: egui::Context,
app: SolveApp,
width: f32,
time: f64,
maximized: bool,
}
impl Win {
fn new(width: f32) -> Self {
let ctx = egui::Context::default();
let mut app = SolveApp::new(Session::default(), 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, 700.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::toolbar::app_bar(app, ui);
if app.custom_titlebar {
resize_zones(ui);
}
});
out.textures_delta.clear();
out
}
/// 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 button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
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)
}
}
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
}
#[test]
fn caption_buttons_sit_in_the_corner_and_nothing_runs_under_them() {
for width in [960.0, 1600.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!([min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
let texts = texts(&out);
let version = texts.iter().find(|(t, _)| t.starts_with('v')).map(|(_, r)| *r);
assert!(version.is_some_and(|r| r.right() <= min.left() - 4.0), "the version stays left of the buttons: {version:?}");
for (t, r) in &texts {
assert!(r.right() <= min.left() || r.top() >= min.bottom(), "`{t}` runs under the caption buttons at {width}");
}
}
}
#[test]
fn no_caption_buttons_without_a_custom_title_bar() {
let mut w = Win::new(1200.0);
w.app.custom_titlebar = false;
w.frame(vec![]);
w.frame(vec![]);
assert_eq!(w.caption(Caption::Close), Rect::NOTHING);
}
#[test]
fn minimize_maximize_and_close() {
let mut w = Win::new(1200.0);
w.frame(vec![]);
let (min, max, close) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).center(), w.caption(Caption::Close).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");
assert!(w.click(close).contains(&ViewportCommand::Close));
assert!(w.app.quit_requested);
}
#[test]
fn empty_bar_space_drags_and_double_click_maximizes() {
let mut w = Win::new(1600.0);
w.frame(vec![]);
let empty = pos2(500.0, 17.0);
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:?}");
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:?}");
// The bar's buttons keep their clicks: dragging from Undo doesn't move the window.
w.time += 1.0;
let undo = pos2(128.0 + 2.0 * 30.0, 17.0);
let cmds = w.gesture(&[undo, undo + vec2(12.0, 4.0), undo + 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, 698.0), South),
(pos2(2.0, 2.0), NorthWest),
(pos2(997.0, 3.0), NorthEast),
(pos2(3.0, 697.0), SouthWest),
(pos2(998.0, 698.0), SouthEast),
] {
let cmds = w.gesture(&[p, p + vec2(6.0, 6.0)]);
assert_eq!(cmds, vec![ViewportCommand::BeginResize(dir)], "at {p:?}");
}
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:?}");
}
}
+75 -63
View File
@@ -38,11 +38,16 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Panel::top("sc_appbar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
let r = ui.max_rect();
// The bar is the window's title bar: its empty space moves the window.
if app.custom_titlebar || app.integrated_titlebar {
crate::titlebar::drag_area(ui, r);
}
let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
// Brand mark: a small solid block drawn in code.
let logo = Rect::from_center_size(pos2(r.left() + 20.0, r.center().y), vec2(20.0, 20.0));
let logo = Rect::from_center_size(pos2(left + 20.0, r.center().y), vec2(20.0, 20.0));
icons::paint(ui.painter(), logo, "box", Color32::from_rgb(220, 226, 236), Color32::from_rgb(90, 160, 240), Color32::WHITE);
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text);
let mut x = r.left() + 128.0;
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text);
let mut x = left + 128.0;
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool {
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
x += 30.0;
@@ -91,13 +96,17 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let tab = Rect::from_min_size(pos2(r.center().x - 110.0, r.top() + 5.0), vec2(220.0, r.height() - 5.0));
ui.painter().rect_filled(tab, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar);
ui.painter().text(tab.center(), Align2::CENTER_CENTER, name, FontId::proportional(13.0), t.text);
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
ui.painter().text(
pos2(r.right() - 12.0, r.center().y),
pos2(r.right() - 12.0 - captions, r.center().y),
Align2::RIGHT_CENTER,
format!("v{}", env!("CARGO_PKG_VERSION")),
FontId::proportional(11.0),
Color32::from_rgb(160, 168, 182),
);
if app.custom_titlebar {
crate::titlebar::caption_buttons(app, ui, r);
}
});
file_menu(app, ui.ctx());
}
@@ -108,67 +117,70 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
let mut close = false;
let resp = egui::Area::new(egui::Id::new("sc_file_area")).fixed_pos(pos2(100.0, 34.0)).order(egui::Order::Foreground).show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
};
if item(ui, "New Design", "Ctrl+N") {
let _ = app.run("NewDocumentCommand", json!({}));
app.fit_view();
close = true;
}
if item(ui, "Open…", "Ctrl+O") {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path(&p);
let resp = egui::Area::new(egui::Id::new("sc_file_area"))
.fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0))
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
};
if item(ui, "New Design", "Ctrl+N") {
let _ = app.run("NewDocumentCommand", json!({}));
app.fit_view();
close = true;
}
close = true;
}
if item(ui, "Insert STEP…", "") {
app.start("FusionImportCommandFromToolbar");
close = true;
}
if item(ui, "Insert Mesh…", "") {
app.start("ParaMeshInsertAlignCommand");
close = true;
}
if item(ui, "Save", "Ctrl+S") {
save(app);
close = true;
}
if item(ui, "Save As…", "") {
save_as(app);
close = true;
}
ui.separator();
if item(ui, "Export STL…", "") {
export(app, "stl");
close = true;
}
if item(ui, "Export STEP…", "") {
export(app, "step");
close = true;
}
if item(ui, "Export 3MF…", "") {
export(app, "3mf");
close = true;
}
if item(ui, "Export OBJ…", "") {
export(app, "obj");
close = true;
}
ui.separator();
if item(ui, "Preferences…", "") {
app.dialog = Some(crate::dialogs::Dialog::preferences());
close = true;
}
if item(ui, "Quit", "") {
app.quit_requested = true;
close = true;
}
if item(ui, "Open…", "Ctrl+O") {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path(&p);
}
close = true;
}
if item(ui, "Insert STEP…", "") {
app.start("FusionImportCommandFromToolbar");
close = true;
}
if item(ui, "Insert Mesh…", "") {
app.start("ParaMeshInsertAlignCommand");
close = true;
}
if item(ui, "Save", "Ctrl+S") {
save(app);
close = true;
}
if item(ui, "Save As…", "") {
save_as(app);
close = true;
}
ui.separator();
if item(ui, "Export STL…", "") {
export(app, "stl");
close = true;
}
if item(ui, "Export STEP…", "") {
export(app, "step");
close = true;
}
if item(ui, "Export 3MF…", "") {
export(app, "3mf");
close = true;
}
if item(ui, "Export OBJ…", "") {
export(app, "obj");
close = true;
}
ui.separator();
if item(ui, "Preferences…", "") {
app.dialog = Some(crate::dialogs::Dialog::preferences());
close = true;
}
if item(ui, "Quit", "") {
app.quit_requested = true;
close = true;
}
});
});
});
let just_opened = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("sc_file_menu_opened"))) == Some(ctx.cumulative_pass_nr());
if close || (!just_opened && resp.response.clicked_elsewhere()) {
ctx.data_mut(|d| d.insert_temp(id, false));
+1
View File
@@ -38,6 +38,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
| `ui.text` | `text` | typed text |
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) |
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) |
| `ui.resize` | `width`, `height` | |
| `app.quit` | — | |