diff --git a/artcraft-native/README.md b/artcraft-native/README.md index 5a771a2650..73b2fcc322 100644 --- a/artcraft-native/README.md +++ b/artcraft-native/README.md @@ -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: diff --git a/artcraft-native/src/app.rs b/artcraft-native/src/app.rs index c866df6598..1cfa07a109 100644 --- a/artcraft-native/src/app.rs +++ b/artcraft-native/src/app.rs @@ -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, settings_open: bool, preview: Option, - logo: egui::TextureHandle, requests: Vec, screenshot: Option, 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,63 +420,87 @@ 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)); }); } + 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; + self.lightbox = None; + }, + ShellAction::ToggleSidebar => self.sidebar_open = !self.sidebar_open, + ShellAction::SignIn => self.open_login(), + ShellAction::SignOut => { + self.backend.logout(); + self.settings_open = false; + }, + ShellAction::OpenSettings => self.settings_open = true, + ShellAction::SetViewMode(mode) => self.view_mode = mode, + ShellAction::ToggleSelect => { + if let Some(feed) = self.current_feed() { + feed.selecting = !feed.selecting; + feed.selected.clear(); + } + }, + } + } + + /// 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(); + 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, }; - 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 { - match action { - ShellAction::Navigate(page) => { - self.page = page; - self.lightbox = None; - }, - ShellAction::ToggleSidebar => self.sidebar_open = !self.sidebar_open, - ShellAction::SignIn => self.open_login(), - ShellAction::SignOut => { - self.backend.logout(); - self.settings_open = false; - }, - ShellAction::OpenSettings => self.settings_open = true, - ShellAction::SetViewMode(mode) => self.view_mode = mode, - ShellAction::ToggleSelect => { - if let Some(feed) = self.current_feed() { - feed.selecting = !feed.selecting; - feed.selected.clear(); + 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 => { + if let Some(page) = other::home(ui, env.signed_in, env.requests) { + self.page = page; + } + }, + Page::CreateImage => self.image.ui(ui, &mut env), + Page::CreateVideo => self.video.ui(ui, &mut env), + Page::Library => other::library(ui, &mut env, &mut self.library), + page => other::coming_soon(ui, page), } - }, - } + }); + }); + }); + if let Some(action) = header_action { + self.apply_shell_action(action); } - } - - 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 mut env = env!(self, ctx); - match self.page { - Page::Home => { - if let Some(page) = other::home(ui, env.signed_in, env.requests) { - self.page = page; - } - }, - Page::CreateImage => self.image.ui(ui, &mut env), - Page::CreateVideo => self.video.ui(ui, &mut env), - Page::Library => other::library(ui, &mut env, &mut self.library), - page => other::coming_soon(ui, page), - } - }); 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) -} diff --git a/artcraft-native/src/main.rs b/artcraft-native/src/main.rs index b4d2b5c5ea..eaace0beb2 100644 --- a/artcraft-native/src/main.rs +++ b/artcraft-native/src/main.rs @@ -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))))) } diff --git a/artcraft-native/src/shell.rs b/artcraft-native/src/shell.rs index 6b6c91354f..ebeb4e0df9 100644 --- a/artcraft-native/src/shell.rs +++ b/artcraft-native/src/shell.rs @@ -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 { +/// 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 { 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)))); + 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); + } + }, + } + }); + 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 { + 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; - - 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| { - if widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() { - action = Some(ShellAction::ToggleSidebar); - } - }); - }); - ui.add_space(6.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,63 +286,35 @@ fn account_row(ui: &mut Ui, name: &str, credits: Option) -> 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 { +/// 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 { 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); - } - }, - } - if let Some((mode, selecting)) = feed_toggles { + 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 -} - fn credits_pill(ui: &mut Ui, credits: u64) -> egui::Response { let text = group_thousands(credits); let galley = ui.painter().layout_no_wrap(text, FontId::new(13.0, theme::semibold()), theme::INK); diff --git a/artcraft-native/src/theme.rs b/artcraft-native/src/theme.rs index 340d4e1175..5df7a398e7 100644 --- a/artcraft-native/src/theme.rs +++ b/artcraft-native/src/theme.rs @@ -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); diff --git a/artcraft-native/src/ui/mod.rs b/artcraft-native/src/ui/mod.rs index 40b7de6762..36c2450504 100644 --- a/artcraft-native/src/ui/mod.rs +++ b/artcraft-native/src/ui/mod.rs @@ -2,3 +2,4 @@ pub mod creator_icons; pub mod icons; pub mod toast; pub mod widgets; +pub mod window; diff --git a/artcraft-native/src/ui/toast.rs b/artcraft-native/src/ui/toast.rs index 221bc94323..97d5ef560b 100644 --- a/artcraft-native/src/ui/toast.rs +++ b/artcraft-native/src/ui/toast.rs @@ -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)] diff --git a/artcraft-native/src/ui/window.rs b/artcraft-native/src/ui/window.rs new file mode 100644 index 0000000000..45c2da5976 --- /dev/null +++ b/artcraft-native/src/ui/window.rs @@ -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)); + } + } +}