mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))));
|
||||
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 {
|
||||
|
||||
@@ -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