mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-13 23:07:40 +00:00
artcraft-native: custom title bar and Claude-style layout
Replace the native window frame with a custom title bar: the ArtCraft mark (home) and sidebar toggle at the left, credits and the account menu at the right, then minimize/maximize/close on Windows and Linux (macOS keeps its traffic lights). The empty space drags the window and a double-click maximizes; the edges and corners resize it. The sidebar now sits flush with the window edge on the darker chrome, and the page lives in an inset, rounded content panel whose header holds the breadcrumbs and feed toggles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5614ad333
commit
244ba8cbcb
8 files changed
+311
-133
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
+71
-62
@@ -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,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)
|
||||
}
|
||||
@@ -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)))))
|
||||
}
|
||||
|
||||
@@ -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))));
|
||||
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<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;
|
||||
|
||||
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<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);
|
||||
}
|
||||
},
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod creator_icons;
|
||||
pub mod icons;
|
||||
pub mod toast;
|
||||
pub mod widgets;
|
||||
pub mod window;
|
||||
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user