breadpad/breadpad-shared/src/theme.rs
Breadway ae296c7154 breadman: rebuild settings as plain GTK4, fix AdwDialog sizing, present-ordering bug
The libadwaita-based settings screen (previous commit) had two real bugs a
closer look (and a screenshot from the real running app) caught:

- AdwSpinRow's internal GtkSpinButton has no width constraint of its own,
  so once the row was widened to 900px, the spin button stretched to fill
  it - the digits and +/- buttons ended up stranded behind a huge empty
  bordered box, the exact bug the design review flagged, just worse.
- bread-theme's shared `entry, spinbutton` rule outlines every field with
  an always-visible @overlay border, which on a light-cream overlay colour
  reads as a stark white outline against the dark theme.

Rather than keep fighting libadwaita's internal row/spin-button opinions
(there's no supported way to reach in and constrain them), settings.rs is
now plain GTK4 mirroring bos-settings' own Row.svelte/NumberField.svelte/
TextField.svelte design exactly: same tokens (12/16px row padding, ch-width
inputs, transparent-at-rest border, accent border only on focus), built on
a plain `list.boxed-list` for the native rounded-corner-run + divider
styling. Full control over sizing, no internal widget to hunt for.

Also fixed a real ordering bug in the editor AdwDialog conversion:
`open_editor` used to call `dialog.present()` internally before returning,
so callers that connected `dialog.connect_map` afterward (screenshot mode)
missed the signal entirely - it can fire synchronously inside `present`.
Presentation now happens at each call site, after wiring `connect_map`.
Also gave the dialog's content an explicit height/vexpand + min-content-
height, since the ScrolledWindow had none and the whole dialog was
collapsing to just its header bar.

breadpad-shared's bread-theme dependency also gets fixed here: it was
still pinned to an old GitHub-mirror tag (v0.2.8) while breadman pinned
the same crate to git.breadway.dev's dev branch - two different copies of
bread-theme compiled into the same binary, so breadman's actual runtime
CSS (built through breadpad_shared::theme) never saw any of the shared
stylesheet fixes above regardless of what breadman's own direct
dependency resolved to.
2026-07-31 08:58:36 +08:00

333 lines
9.4 KiB
Rust

pub use bread_theme::{load_palette, Palette};
/// Apply breadpad/breadman's stylesheet and keep it live across palette changes.
/// [`build_css`] bundles the shared component sheet with the app's own rules from
/// the current pywal palette; `bread_theme::gtk::apply_app_css` re-runs this
/// whenever `bread-theme reload` rewrites the shared theme file, so the UI
/// recolours in place (and re-reads the user's `style.css` override too).
pub fn apply_live() {
bread_theme::gtk::apply_app_css(|| {
let palette = load_palette();
let user_css = std::fs::read_to_string(crate::config::style_css_path()).ok();
build_css(&palette, user_css.as_deref())
});
}
/// Generate the full breadpad/breadman CSS string. The base — `@define-color`
/// palette, fonts, and generic widget styling — comes from the shared
/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the
/// rest of the ecosystem. Only breadpad-specific component rules are appended.
pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
// Shared ecosystem base (define-colors incl. accent, font, buttons, entries,
// switches, lists, cards, scrollbars). `overlay` here is color7 — consistent
// with every other bread app (breadpad previously mapped it to color0).
let mut css = bread_theme::stylesheet(palette);
css.push_str(
r#"
/* breadpad/breadman-specific components */
window { border-radius: 8px; }
/* breadman/views/settings.rs — matches bos-settings' Row.svelte/
NumberField.svelte/TextField.svelte exactly (same design tokens: 12/16px
row padding, transparent-at-rest input border, ch-width inputs) rather
than libadwaita's own AdwActionRow/AdwSpinRow padding and internal
spin-button sizing, which run noticeably taller/wider and don't expose a
way to constrain from the outside. `list.boxed-list` already gets its
surface fill + radius from the shared stylesheet; this only adds the
compact row padding and the divider between rows. */
list.boxed-list row.field-row { padding: 12px 16px; min-height: 0; }
list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(@on-surface, 0.08); }
.field-row-subtitle { opacity: 0.6; font-size: 12px; }
.field-input {
background-color: @bg;
color: @on-surface;
border: 1px solid transparent;
border-radius: 6px;
padding: 4px 8px;
}
.field-input:focus-within { border-color: @accent; outline: none; }
.popup-entry {
background: @bg;
color: @fg;
border: 2px solid @blue;
border-radius: 6px;
padding: 14px;
font-size: 14px;
caret-color: @fg;
}
.popup-entry:focus {
outline: none;
border-color: @teal;
}
.type-chip {
background: @overlay;
color: @on-overlay;
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
margin: 4px;
}
.type-chip.active {
background: @blue;
color: @on-accent;
}
/* Per-type tint, matching the note-card-{type} accent-bar colors below -
the flat cream badge was the same high-contrast fill for every type,
out-shouting note body text while telling you nothing extra. */
.type-chip-todo { background: alpha(@green, 0.18); color: @green; }
.type-chip-reminder { background: alpha(@yellow, 0.18); color: @yellow; }
.type-chip-idea { background: alpha(@pink, 0.18); color: @pink; }
.type-chip-question { background: alpha(@teal, 0.18); color: @teal; }
.type-chip-note { background: alpha(@blue, 0.18); color: @blue; }
.confirm-button {
background: @blue;
color: @on-accent;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-weight: bold;
}
.note-card {
background: shade(@bg, 1.1);
border-radius: 8px;
padding: 12px;
margin: 8px;
border-left: 3px solid @blue;
}
.note-card:hover {
background: shade(@bg, 1.2);
}
.search-entry {
background: shade(@bg, 1.1);
color: @fg;
border: 1px solid @overlay;
border-radius: 6px;
padding: 8px 12px;
}
.search-entry:focus {
border-color: @blue;
outline: none;
}
.sidebar-row {
padding: 8px 12px;
font-size: 14px;
transition: background 100ms ease;
}
.sidebar-row:hover:not(:selected) {
background: shade(@bg, 1.1);
}
.sidebar-row:selected {
background: @blue;
color: @on-accent;
font-weight: 500;
}
.sidebar-section-label {
color: alpha(@fg, 0.5);
font-size: 11px;
font-weight: 600;
padding: 12px 12px 8px 12px;
letter-spacing: 0.5px;
}
.action-btn {
background: transparent;
border: none;
border-radius: 6px;
padding: 2px 7px;
min-width: 28px;
min-height: 28px;
font-size: 14px;
}
.action-btn:hover {
background: shade(@bg, 1.3);
}
.done-btn { color: @green; }
.done-btn:hover { background: alpha(@green, 0.15); }
.edit-btn { color: @blue; }
.edit-btn:hover { background: alpha(@blue, 0.15); }
/* Fixed red, not @red - pywal can hand `red` any hue depending on the
wallpaper (see bread-theme's button.destructive-action for the same
reasoning), which would make delete indistinguishable from a normal
accent action. */
.danger-btn { color: #e01b24; }
.danger-btn:hover { background: alpha(#e01b24, 0.15); }
.note-card-todo { border-left-color: @green; }
.note-card-reminder { border-left-color: @yellow; }
.note-card-idea { border-left-color: @pink; }
.note-card-question { border-left-color: @teal; }
.note-card-note { border-left-color: @blue; }
.reminder-window {
background: @bg;
border: 1px solid @overlay;
border-radius: 8px;
}
.reminder-emoji { font-size: 20px; }
.reminder-title {
font-size: 12px;
font-weight: bold;
color: alpha(@fg, 0.6);
letter-spacing: 0.5px;
}
.reminder-time {
font-size: 12px;
color: alpha(@fg, 0.5);
}
.reminder-body {
font-size: 18px;
font-weight: bold;
color: @fg;
}
/* Dismiss and Snooze are both secondary/outline actions and should read at
equal weight - Dismiss used to sit at 0.6 alpha next to Snooze's full
opacity, which made the button that closes the reminder look disabled. */
.reminder-dismiss {
background: transparent;
border: 1px solid @overlay;
border-radius: 8px;
padding: 8px 16px;
color: @fg;
}
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
.reminder-snooze {
background: transparent;
border: 1px solid @overlay;
border-radius: 8px;
padding: 8px 16px;
color: @fg;
}
.reminder-snooze:hover { background: shade(@bg, 1.1); }
/* Left-aligned (the button's child label sets xalign itself), full-width
row with a hairline divider so the list reads as distinct clickable rows
even before hover - a hover tint alone doesn't show up in a static
reading of the popover's default state. */
.snooze-option {
background: transparent;
border: none;
border-radius: 6px;
padding: 10px 12px;
color: @fg;
border-bottom: 1px solid alpha(@overlay, 0.15);
}
.snooze-option:hover { background: shade(@bg, 1.2); }
.snooze-custom-entry {
background: @bg;
color: @fg;
border: 1px solid @overlay;
border-radius: 6px;
padding: 8px 12px;
margin: 4px;
}
.snooze-custom-entry:focus-within { border-color: @blue; outline: none; }
/* Matches the reminder alert card's flat-bordered elevation (1px border,
8px radius, no shadow) instead of GTK's default arrow+drop-shadow popover
chrome - the two surfaces used to speak two different elevation
languages. */
popover.snooze-popover > contents {
border: 1px solid @overlay;
box-shadow: none;
}
"#);
if let Some(extra) = user_css {
css.push('\n');
css.push_str(extra);
}
css
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_defines_bg_color() {
let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
}
#[test]
fn css_defines_all_named_colors() {
let css = build_css(&Palette::default(), None);
for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
}
}
#[test]
fn css_contains_window_rule() {
let css = build_css(&Palette::default(), None);
assert!(css.contains("window {"));
assert!(css.contains("background-color: @bg"));
}
#[test]
fn css_contains_popup_entry_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".popup-entry {"), "css: {}", &css[300..600]);
}
#[test]
fn css_contains_note_card_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".note-card {"));
}
#[test]
fn css_appends_user_css() {
let user = ".my-override { color: hotpink; }";
let css = build_css(&Palette::default(), Some(user));
assert!(css.contains(".my-override { color: hotpink; }"));
}
#[test]
fn css_without_user_css_omits_user_rules() {
let css = build_css(&Palette::default(), None);
assert!(!css.contains(".my-override"));
}
#[test]
fn css_reflects_custom_palette_colors() {
let mut p = Palette::default();
p.background = "#deadbe".into();
p.color4 = "#cafe00".into();
let css = build_css(&p, None);
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);
}
}