artcraft-native: custom title bar and Claude-style layout (#2068)

## What

The native app now draws its own window chrome, laid out like the Claude
desktop app.

- **Custom title bar** (40 px) replaces the native frame:
  - Left: the ArtCraft mark (goes Home) and the sidebar toggle.
- Right: credits, the account menu or Sign in, then minimize,
maximize/restore and close.
  - Dragging the empty space moves the window; a double-click maximizes.
  - On macOS the traffic lights stay, with room left for them.
- **Sidebar** is flush with the window's left edge, on the darker
chrome, with no floating card.
- **Content panel** is inset 8 px from the window edges, with rounded
corners and a hairline border. Its header holds the breadcrumbs and the
select and grid/list toggles.
- **Borderless window**: the edges and corners resize it, and a hairline
outlines it (hidden when maximized).

## Notes

- The window chrome helpers (`ui/window.rs`) are ported from the
ArtCraft Launcher, which uses the same egui 0.36.
- The mark is the in-repo `services/artcraft.svg`, tinted brand blue.
The old wordmark loader is removed.
- Toasts are moved down to sit below the new header.

## Testing

- `cargo test -p artcraft_native`: 29 tests pass.
- `cargo clippy -p artcraft_native --all-targets --no-deps` is clean.
- Screenshot-verified against a live account.
- Not tested by hand: dragging, resizing and the caption buttons.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-11 00:54:50 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b5614ad333
commit bb27304b8b
8 changed files with 311 additions and 133 deletions
+2 -2
View File
@@ -37,8 +37,8 @@ The workspace's default member is the Tauri app, so pass `-p artcraft_native`.
| `pages` | Create Image, Create Video, Home, Library, and the shared page shell |
| `feed` | Generation feed: running, failed and finished jobs, library paging, grid and list |
| `overlays` | Lightbox, library picker, sign-in and settings dialogs |
| `shell` | Sidebar and top bar |
| `theme`, `ui` | Colour and type tokens, Lucide-style vector icons, widgets, toasts |
| `shell` | Custom title bar, sidebar flush to the window edge, content panel header |
| `theme`, `ui` | Tokens, Lucide-style icons, widgets, toasts, window chrome (drag, resize, caption buttons) |
How the backend works:
+41 -32
View File
@@ -25,7 +25,7 @@ use crate::prompt_box::refs::{RefKind, RefMedia};
use crate::shell::{self, Account, Page, ShellAction};
use crate::theme;
use crate::ui::toast::Toasts;
use crate::ui::widgets;
use crate::ui::{creator_icons, widgets, window};
/// Builds the page environment from the app's fields (disjoint borrows, so pages can be
/// borrowed mutably alongside it).
@@ -88,7 +88,6 @@ pub struct ArtcraftApp {
login: Option<LoginDialog>,
settings_open: bool,
preview: Option<String>,
logo: egui::TextureHandle,
requests: Vec<AppRequest>,
screenshot: Option<ScreenshotRequest>,
next_poll: f64,
@@ -109,7 +108,7 @@ impl ArtcraftApp {
backend.load_models(Modality::Image);
backend.load_models(Modality::Video);
backend.refresh_session();
Self { cache, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, settings_open: false, preview: None, logo: load_logo(&cc.egui_ctx), requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }
Self { cache, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, settings_open: false, preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }
}
// --- Events from the backend -------------------------------------------------------------
@@ -421,24 +420,26 @@ impl ArtcraftApp {
ctx.request_repaint_after(Duration::from_secs(1));
}
/// The title bar and the sidebar (both on the window chrome).
fn shell(&mut self, ui: &mut Ui) {
let account = Account { username: self.user.as_ref().map(|u| u.username.as_str()), display_name: self.user.as_ref().map(|u| u.display_name.as_str()).filter(|n| !n.is_empty()), credits: self.credits };
let mark = creator_icons::texture(ui.ctx(), "artcraft");
let mut actions = Vec::new();
let chrome = egui::Frame::NONE.fill(theme::CHROME);
egui::Panel::top("titlebar").exact_size(shell::TITLEBAR_HEIGHT).resizable(false).frame(chrome).show(ui, |ui| {
actions.extend(shell::titlebar(ui, &account, &mark));
});
if self.sidebar_open {
egui::Panel::left("sidebar").exact_size(shell::sidebar_width()).resizable(false).frame(egui::Frame::NONE.fill(theme::BG)).show(ui, |ui| {
actions.extend(shell::sidebar(ui, self.page, &account, &self.logo));
egui::Panel::left("sidebar").exact_size(shell::SIDEBAR_WIDTH).resizable(false).frame(chrome).show(ui, |ui| {
actions.extend(shell::sidebar(ui, self.page, &account));
});
}
let toggles = match self.page {
Page::CreateImage => Some((self.view_mode, self.image.feed.selecting)),
Page::CreateVideo => Some((self.view_mode, self.video.feed.selecting)),
Page::Library => Some((self.view_mode, self.library.selecting)),
_ => None,
};
egui::Panel::top("topbar").exact_size(shell::topbar_height()).resizable(false).frame(egui::Frame::NONE).show(ui, |ui| {
actions.extend(shell::topbar(ui, self.page, &account, toggles, self.sidebar_open));
});
for action in actions {
self.apply_shell_action(action);
}
}
fn apply_shell_action(&mut self, action: ShellAction) {
match action {
ShellAction::Navigate(page) => {
self.page = page;
@@ -460,11 +461,28 @@ impl ArtcraftApp {
},
}
}
}
/// The content panel: inset from the window edges with rounded corners, its header on top
/// and the page below.
fn page_ui(&mut self, ui: &mut Ui) {
let ctx = ui.ctx().clone();
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(theme::BG)).show(ui, |ui| {
let gap = shell::PANEL_GAP;
let margin = egui::Margin { left: if self.sidebar_open { 0 } else { gap }, right: gap, top: 0, bottom: gap };
let toggles = match self.page {
Page::CreateImage => Some((self.view_mode, self.image.feed.selecting)),
Page::CreateVideo => Some((self.view_mode, self.video.feed.selecting)),
Page::Library => Some((self.view_mode, self.library.selecting)),
_ => None,
};
let mut header_action = None;
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(theme::CHROME).inner_margin(margin)).show(ui, |ui| {
let panel = egui::Frame::new().fill(theme::BG).stroke(theme::hairline()).corner_radius(shell::PANEL_RADIUS);
panel.show(ui, |ui| {
ui.set_min_size(ui.available_size());
egui::Panel::top("page-header").exact_size(shell::HEADER_HEIGHT).resizable(false).frame(egui::Frame::NONE).show(ui, |ui| {
header_action = shell::page_header(ui, self.page, toggles);
});
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
let mut env = env!(self, ctx);
match self.page {
Page::Home => {
@@ -478,6 +496,11 @@ impl ArtcraftApp {
page => other::coming_soon(ui, page),
}
});
});
});
if let Some(action) = header_action {
self.apply_shell_action(action);
}
self.prefetch_list_prompts();
}
@@ -647,6 +670,7 @@ impl eframe::App for ArtcraftApp {
}
self.overlays(&ctx);
self.toasts.show(&ctx);
window::window_edges(&ctx);
if let Some(shot) = &mut self.screenshot {
shot.tick(&ctx);
}
@@ -658,7 +682,7 @@ impl eframe::App for ArtcraftApp {
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
theme::BG.to_normalized_gamma_f32()
theme::CHROME.to_normalized_gamma_f32()
}
}
@@ -712,18 +736,3 @@ fn file_name_for(item: &FeedItem) -> String {
let ext = item.full_url.rsplit('.').next().filter(|e| e.len() <= 4 && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or(if item.kind == MediaKind::Video { "mp4" } else { "png" });
format!("artcraft_{}.{ext}", item.token)
}
/// The ArtCraft wordmark (the Tauri app's white-on-dark SVG), rasterized for the sidebar.
fn load_logo(ctx: &egui::Context) -> egui::TextureHandle {
const LOGO: &[u8] = include_bytes!("../../frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg");
/// Twice the 20 pt the sidebar draws it at, for high-DPI screens.
const HEIGHT_PX: f32 = 48.0;
let image = resvg::usvg::Tree::from_data(LOGO, &resvg::usvg::Options::default()).ok().and_then(|tree| {
let scale = HEIGHT_PX / tree.size().height();
let (w, h) = ((tree.size().width() * scale).ceil() as u32, HEIGHT_PX as u32);
let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)?;
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
Some(egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pixmap.data()))
});
ctx.load_texture("artcraft-logo", image.unwrap_or_else(|| egui::ColorImage::filled([1, 1], egui::Color32::TRANSPARENT)), egui::TextureOptions::LINEAR)
}
+2
View File
@@ -21,6 +21,8 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
viewport = viewport.with_icon(icon);
}
// The app draws its own title bar; macOS keeps its traffic lights over it.
viewport = if cfg!(target_os = "macos") { viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false) } else { viewport.with_decorations(false) };
let options = eframe::NativeOptions { viewport, persist_window: true, ..Default::default() };
eframe::run_native("ArtCraft", options, Box::new(|cc| Ok(Box::new(app::ArtcraftApp::new(cc)))))
}
+72 -63
View File
@@ -1,16 +1,19 @@
//! The app frame from the webapp: a floating sidebar (`app-sidebar`) and the top bar with
//! breadcrumbs, feed toggles, credits and the account menu (`topbar`).
//! The app frame: a custom title bar (mark, sidebar toggle, account, caption buttons), a sidebar
//! flush with the window edge, and the content panel's header (breadcrumbs and feed toggles).
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::feed::grid::ViewMode;
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
use crate::ui::{widgets, window};
const SIDEBAR_WIDTH: f32 = 232.0;
const SIDEBAR_INSET: f32 = 8.0;
const TOPBAR_HEIGHT: f32 = 57.0;
pub const SIDEBAR_WIDTH: f32 = 232.0;
pub const TITLEBAR_HEIGHT: f32 = 40.0;
pub const HEADER_HEIGHT: f32 = 48.0;
/// The content panel's gap to the window edges, and its corner radius (Claude-app style).
pub const PANEL_GAP: i8 = 8;
pub const PANEL_RADIUS: u8 = 8;
const ROW_HEIGHT: f32 = 32.0;
pub const DISCORD_URL: &str = "https://discord.gg/artcraft";
pub const GITHUB_URL: &str = "https://github.com/storytold/artcraft";
@@ -133,26 +136,66 @@ pub enum ShellAction {
ToggleSelect,
}
/// The sidebar. Returns the user's action, if any.
pub fn sidebar(ui: &mut Ui, page: Page, account: &Account<'_>, logo: &egui::TextureHandle) -> Option<ShellAction> {
/// The custom title bar: the ArtCraft mark (home) and the sidebar toggle at the left, the
/// account and (on Windows and Linux) the caption buttons at the right; the rest drags the window.
pub fn titlebar(ui: &mut Ui, account: &Account<'_>, mark: &egui::TextureHandle) -> Option<ShellAction> {
let mut action = None;
let rect = ui.max_rect().shrink(SIDEBAR_INSET);
ui.painter().rect(rect, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside);
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 10.0))));
let ui = &mut inner;
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
let h = 20.0;
let w = h * logo.size_vec2().x / logo.size_vec2().y.max(1.0);
ui.add(egui::Image::new(logo).fit_to_exact_size(vec2(w, h)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
window::title_bar_drag(ui);
let rect = ui.max_rect();
let mut bar = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
let ui = &mut bar;
ui.spacing_mut().item_spacing.x = 4.0;
ui.add_space(window::leading_inset() + 10.0);
let (mark_rect, mark_resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
let hovered = mark_resp.hovered();
if hovered {
ui.painter().rect_filled(mark_rect, theme::RADIUS, theme::WASH);
}
ui.painter().image(mark.id(), Rect::from_center_size(mark_rect.center(), vec2(18.0, 16.0)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), theme::ACCENT);
if mark_resp.on_hover_text("Home").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
action = Some(ShellAction::Navigate(Page::Home));
}
if widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() {
action = Some(ShellAction::ToggleSidebar);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if window::custom_caption_buttons() {
ui.spacing_mut().item_spacing.x = 0.0;
window::caption_buttons(ui, TITLEBAR_HEIGHT);
ui.add_space(12.0);
ui.spacing_mut().item_spacing.x = 8.0;
} else {
ui.add_space(12.0);
}
match account.username {
Some(_) => {
if let Some(a) = account_menu(ui, account) {
action = Some(a);
}
if let Some(credits) = account.credits {
if credits_pill(ui, credits).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing")));
}
}
},
None => {
if widgets::button(ui, None, "Sign in", widgets::Kind::Primary, 0.0, 28.0).clicked() {
action = Some(ShellAction::SignIn);
}
},
}
});
});
ui.add_space(6.0);
action
}
/// The sidebar, flush with the window's left edge. Returns the user's action, if any.
pub fn sidebar(ui: &mut Ui, page: Page, account: &Account<'_>) -> Option<ShellAction> {
let mut action = None;
let rect = ui.max_rect();
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 4.0))));
let ui = &mut inner;
ui.spacing_mut().item_spacing.y = 2.0;
if nav_row(ui, Page::Home, page == Page::Home) {
action = Some(ShellAction::Navigate(Page::Home));
}
@@ -174,12 +217,11 @@ pub fn sidebar(ui: &mut Ui, page: Page, account: &Account<'_>, logo: &egui::Text
}
// The account sits at the bottom, like the webapp's download button.
let bottom = Rect::from_min_max(pos2(rect.left() + 10.0, rect.bottom() - 58.0), pos2(rect.right() - 10.0, rect.bottom() - 10.0));
let bottom = Rect::from_min_max(pos2(rect.left() + 10.0, rect.bottom() - 56.0), pos2(rect.right() - 10.0, rect.bottom() - 8.0));
let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(bottom));
match account.username {
Some(username) => {
let resp = account_row(&mut foot, account.display_name.unwrap_or(username), account.credits);
if resp.clicked() {
if account_row(&mut foot, account.display_name.unwrap_or(username), account.credits).clicked() {
action = Some(ShellAction::OpenSettings);
}
},
@@ -192,11 +234,6 @@ pub fn sidebar(ui: &mut Ui, page: Page, account: &Account<'_>, logo: &egui::Text
action
}
/// The sidebar's full width (including its inset), for the panel.
pub fn sidebar_width() -> f32 {
SIDEBAR_WIDTH + SIDEBAR_INSET * 2.0
}
fn nav_row(ui: &mut Ui, page: Page, selected: bool) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
let hovered = resp.hovered();
@@ -249,61 +286,33 @@ fn account_row(ui: &mut Ui, name: &str, credits: Option<u64>) -> egui::Response
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Settings")
}
/// The top bar. `feed_toggles` shows the select and grid/list toggles (create pages).
pub fn topbar(ui: &mut Ui, page: Page, account: &Account<'_>, feed_toggles: Option<(ViewMode, bool)>, sidebar_open: bool) -> Option<ShellAction> {
/// The content panel's header: breadcrumbs, and the select and grid/list toggles on feed pages.
pub fn page_header(ui: &mut Ui, page: Page, feed_toggles: Option<(ViewMode, bool)>) -> Option<ShellAction> {
let mut action = None;
let rect = ui.max_rect();
ui.painter().rect_filled(rect, 0.0, theme::PANEL);
ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, theme::hairline());
let mut bar = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(20.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let ui = &mut bar;
ui.spacing_mut().item_spacing.x = 8.0;
if !sidebar_open && widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() {
action = Some(ShellAction::ToggleSidebar);
}
let (section, leaf) = page.crumbs();
widgets::hud(ui, section, theme::MUTED);
let (chev, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
icons::paint(ui.painter(), chev, Icon::ChevronRight, theme::FAINT);
let galley = widgets::caps(ui, leaf, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::PANEL);
let galley = widgets::caps(ui, leaf, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::BG);
let (crumb, _) = ui.allocate_exact_size(galley.size() + vec2(12.0, 8.0), Sense::hover());
ui.painter().rect_filled(crumb, 0.0, theme::INK);
ui.painter().galley(crumb.center() - galley.size() / 2.0, galley, theme::PANEL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
match account.username {
Some(_) => {
if let Some(a) = account_menu(ui, account) {
action = Some(a);
}
if let Some(credits) = account.credits {
let resp = credits_pill(ui, credits);
if resp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing")));
}
}
},
None => {
if widgets::button(ui, None, "Sign in", widgets::Kind::Primary, 0.0, 32.0).clicked() {
action = Some(ShellAction::SignIn);
}
},
}
ui.painter().galley(crumb.center() - galley.size() / 2.0, galley, theme::BG);
if let Some((mode, selecting)) = feed_toggles {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(m) = view_toggle(ui, mode) {
action = Some(ShellAction::SetViewMode(m));
}
if segmented(ui, &[(Icon::SquareCheck, if selecting { "Exit selection" } else { "Select items" }, selecting)]).is_some() {
action = Some(ShellAction::ToggleSelect);
}
}
});
action
}
pub fn topbar_height() -> f32 {
TOPBAR_HEIGHT
action
}
fn credits_pill(ui: &mut Ui, credits: u64) -> egui::Response {
+2
View File
@@ -24,6 +24,8 @@ pub const BG: Color32 = hex(0x121316);
pub const PANEL: Color32 = hex(0x101014);
/// `ui-controls`: the sidebar, the prompt box and toolbar buttons.
pub const CONTROLS: Color32 = hex(0x1e1f22);
/// The window chrome behind the content panel: the title bar and the sidebar.
pub const CHROME: Color32 = hex(0x0a0a0c);
/// `ui-sunken`: wells and inputs.
pub const SUNKEN: Color32 = hex(0x060607);
+1
View File
@@ -2,3 +2,4 @@ pub mod creator_icons;
pub mod icons;
pub mod toast;
pub mod widgets;
pub mod window;
+2 -2
View File
@@ -9,8 +9,8 @@ use crate::ui::icons::{self, Icon};
const DEFAULT_DURATION: Duration = Duration::from_secs(4);
const MAX_WIDTH: f32 = 448.0;
/// Below the top bar, like the webapp's `top-16 right-5`.
const TOP_OFFSET: f32 = 64.0;
/// Below the title bar and the panel header (the webapp's `top-16 right-5`, shifted).
const TOP_OFFSET: f32 = 100.0;
const RIGHT_OFFSET: f32 = 20.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+155
View File
@@ -0,0 +1,155 @@
//! Window chrome for the undecorated window: dragging and maximizing from the title bar, the
//! Windows-style caption buttons, and resizing from the edges.
use egui::{Color32, Rect, Response, Sense, Stroke, Ui, vec2};
use crate::theme;
/// How far in from the window's edges a drag resizes. Title bar controls leave this band alone.
const RESIZE_EDGE: f32 = 5.0;
/// Caption buttons (Windows' own are 46 × the title bar height).
const BUTTON_WIDTH: f32 = 46.0;
/// The close button's hover red (Windows').
const CLOSE_RED: Color32 = Color32::from_rgb(0xc4, 0x2b, 0x1c);
/// Whether this platform draws its own title bar buttons (macOS keeps the traffic lights).
pub fn custom_caption_buttons() -> bool {
!cfg!(target_os = "macos")
}
/// Space to leave at the title bar's left for macOS's traffic lights.
pub fn leading_inset() -> f32 {
if cfg!(target_os = "macos") {
76.0
} else {
0.0
}
}
/// Dragging the title bar's empty space moves the window; double-clicking maximizes or restores.
/// Call before drawing the title bar's controls so they stay on top.
pub fn title_bar_drag(ui: &mut Ui) {
let r = ui.max_rect();
let rect = Rect::from_min_max(r.min + vec2(RESIZE_EDGE, RESIZE_EDGE), r.max - vec2(RESIZE_EDGE, 0.0));
let resp = ui.interact(rect, ui.id().with("title-bar-drag"), Sense::click_and_drag());
if resp.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if resp.double_clicked() {
let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Caption {
Minimize,
Maximize,
Restore,
Close,
}
/// Minimize, maximize (or restore) and close, `height` tall, for a right-to-left layout so close
/// ends up at the right edge.
pub fn caption_buttons(ui: &mut Ui, height: f32) {
let ctx = ui.ctx().clone();
let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
if caption_button(ui, Caption::Close, height).clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
let size = if maximized { Caption::Restore } else { Caption::Maximize };
if caption_button(ui, size, height).clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
}
if caption_button(ui, Caption::Minimize, height).clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
}
fn caption_button(ui: &mut Ui, button: Caption, height: f32) -> Response {
let (rect, _) = ui.allocate_exact_size(vec2(BUTTON_WIDTH, height), Sense::hover());
// Clickable below the resize band along the top (and, for close, the right edge).
let right = if button == Caption::Close { RESIZE_EDGE } else { 0.0 };
let hit = Rect::from_min_max(rect.min + vec2(0.0, RESIZE_EDGE), rect.max - vec2(right, 0.0));
let resp = ui.interact(hit, ui.id().with(("caption", button as u8)), Sense::click());
let (fill, ink) = match (button, resp.hovered()) {
(Caption::Close, true) => (CLOSE_RED, Color32::WHITE),
(_, true) => (theme::WASH_HOVER, theme::INK_STRONG),
(_, false) => (Color32::TRANSPARENT, theme::MUTED),
};
let p = ui.painter();
p.rect_filled(rect, 0.0, fill);
let c = rect.center();
let stroke = Stroke::new(1.0, ink);
// Half the glyph's size: 10 px, like Windows' own caption buttons.
let s = 5.0;
let label = match button {
Caption::Minimize => {
p.hline(c.x - s..=c.x + s, c.y, stroke);
"Minimize"
},
Caption::Maximize => {
p.rect_stroke(Rect::from_center_size(c, vec2(2.0 * s, 2.0 * s)), 0.0, stroke, egui::StrokeKind::Inside);
"Maximize"
},
Caption::Restore => {
// A window in front of another.
let front = Rect::from_min_size(c + vec2(-s, -s + 2.0), vec2(2.0 * s - 2.0, 2.0 * s - 2.0));
p.rect_stroke(front, 0.0, stroke, egui::StrokeKind::Inside);
p.hline(c.x - s + 2.0..=c.x + s, c.y - s, stroke);
p.vline(c.x + s, c.y - s..=c.y + s - 2.0, stroke);
"Restore"
},
Caption::Close => {
p.line_segment([c + vec2(-s, -s), c + vec2(s, s)], stroke);
p.line_segment([c + vec2(-s, s), c + vec2(s, -s)], stroke);
"Close"
},
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.on_hover_text(label)
}
/// A hairline round the window, and resizing from its edges and corners (when not maximized).
pub fn window_edges(ctx: &egui::Context) {
use egui::CursorIcon as C;
use egui::viewport::ResizeDirection as D;
let (maximized, fullscreen) = ctx.input(|i| (i.viewport().maximized.unwrap_or(false), i.viewport().fullscreen.unwrap_or(false)));
if maximized || fullscreen || !custom_caption_buttons() {
return;
}
let r = ctx.content_rect();
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("window-border"))).rect_stroke(r, 0.0, theme::hairline(), egui::StrokeKind::Inside);
let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)) else {
return;
};
let near = |d: f32| d < RESIZE_EDGE;
// A corner takes a little of each edge next to it.
let corner = |d: f32| d < 3.0 * RESIZE_EDGE;
let (left, right, top, bottom) = (p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y);
let hit = if (near(top) && corner(left)) || (near(left) && corner(top)) {
Some((D::NorthWest, C::ResizeNorthWest))
} else if (near(top) && corner(right)) || (near(right) && corner(top)) {
Some((D::NorthEast, C::ResizeNorthEast))
} else if (near(bottom) && corner(left)) || (near(left) && corner(bottom)) {
Some((D::SouthWest, C::ResizeSouthWest))
} else if (near(bottom) && corner(right)) || (near(right) && corner(bottom)) {
Some((D::SouthEast, C::ResizeSouthEast))
} else if near(top) {
Some((D::North, C::ResizeNorth))
} else if near(bottom) {
Some((D::South, C::ResizeSouth))
} else if near(left) {
Some((D::West, C::ResizeWest))
} else if near(right) {
Some((D::East, C::ResizeEast))
} else {
None
};
if let Some((direction, cursor)) = hit {
ctx.set_cursor_icon(cursor);
if ctx.input(|i| i.pointer.primary_pressed()) {
ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction));
}
}
}