Release 0.4.0: GUI design pass + td:/rem:/note:/q: prefix grammar
All checks were successful
Mirror to GitHub / mirror (push) Successful in 1s
release / build (push) Successful in 2m10s
Build and publish package / package (push) Successful in 3m8s

Unifies the "selected" visual language across breadman's sidebar and
breadpad's type chips (ghost default, solid accent when active), gives
note cards a real surface instead of just a left border, caps the note
list width so type chips and action buttons sit next to the title they
belong to, and swaps the sidebar's full-color emoji for theme-safe
monochrome glyphs plus per-type colored dots.

breadman also gets live per-row sidebar counts, a note-count footer,
relative timestamps with absolute-time tooltips, and a de-emphasized
Errors row.

breadpad's popup gets an explicit short-prefix grammar (td:, rem:,
idea:, note:, q:) that live-highlights the matching chip as you type,
a keyboard-shortcut hint row, and a confirm button that's visually
separated from the chip row instead of reading as a sixth pill.
This commit is contained in:
Breadway 2026-07-04 10:53:52 +08:00
parent b828f8ec79
commit 6fea1af544
6 changed files with 404 additions and 51 deletions

8
Cargo.lock generated
View file

@ -315,7 +315,7 @@ dependencies = [
[[package]] [[package]]
name = "breadman" name = "breadman"
version = "0.3.4" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"breadpad-shared", "breadpad-shared",
@ -332,7 +332,7 @@ dependencies = [
[[package]] [[package]]
name = "breadpad" name = "breadpad"
version = "0.3.4" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"breadpad-shared", "breadpad-shared",
@ -351,7 +351,7 @@ dependencies = [
[[package]] [[package]]
name = "breadpad-shared" name = "breadpad-shared"
version = "0.3.4" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-theme", "bread-theme",
@ -377,7 +377,7 @@ dependencies = [
[[package]] [[package]]
name = "breadpad-test" name = "breadpad-test"
version = "0.3.4" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"breadpad-shared", "breadpad-shared",

View file

@ -8,7 +8,7 @@ members = [
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.3.4" version = "0.4.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Breadway"] authors = ["Breadway"]

View file

@ -6,7 +6,7 @@ use breadpad_shared::{
store::Store, store::Store,
types::{Note, NoteType, RecurrenceRule}, types::{Note, NoteType, RecurrenceRule},
}; };
use chrono::Local; use chrono::{DateTime, Local, Utc};
use gtk4::{glib, prelude::*}; use gtk4::{glib, prelude::*};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@ -69,10 +69,21 @@ struct AppState {
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>, errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
active_view: Rc<RefCell<String>>, active_view: Rc<RefCell<String>>,
stack: gtk4::Stack, stack: gtk4::Stack,
/// Sidebar row id ("all", "upcoming", "archive", or a note type name) ->
/// its count `Label`, so counts can be refreshed in place after every
/// `rebuild_stack` without rebuilding the sidebar itself.
sidebar_counts: Rc<RefCell<Vec<(String, gtk4::Label)>>>,
status_label: gtk4::Label,
} }
impl AppState { impl AppState {
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack) -> Self { fn new(
store: Arc<Store>,
notes: Vec<Note>,
cfg: Config,
stack: gtk4::Stack,
status_label: gtk4::Label,
) -> Self {
AppState { AppState {
store, store,
notes: Rc::new(RefCell::new(notes)), notes: Rc::new(RefCell::new(notes)),
@ -80,6 +91,8 @@ impl AppState {
errors: Rc::new(RefCell::new(Vec::new())), errors: Rc::new(RefCell::new(Vec::new())),
active_view: Rc::new(RefCell::new("all".to_string())), active_view: Rc::new(RefCell::new("all".to_string())),
stack, stack,
sidebar_counts: Rc::new(RefCell::new(Vec::new())),
status_label,
} }
} }
@ -192,6 +205,40 @@ fn rebuild_stack(state: &AppState) {
// Errors // Errors
let errors_view = views::errors::build(&errors); let errors_view = views::errors::build(&errors);
state.stack.add_named(&errors_view, Some("errors")); state.stack.add_named(&errors_view, Some("errors"));
update_counts_and_status(state);
}
/// Refreshes the sidebar's per-row counts and the content pane's footer
/// note count from the current `state.notes`. Cheap enough to call on every
/// rebuild — five type counts plus all/upcoming/archive over a note list
/// that in practice stays small.
fn update_counts_and_status(state: &AppState) {
let notes = state.notes.borrow();
let total = notes.iter().filter(|n| !n.done).count();
state
.status_label
.set_label(&format!("{total} note{}", if total == 1 { "" } else { "s" }));
for (key, label) in state.sidebar_counts.borrow().iter() {
let n = match key.as_str() {
"all" => total,
"upcoming" => notes
.iter()
.filter(|n| {
!n.done
&& matches!(n.note_type, NoteType::Reminder | NoteType::Todo)
&& n.effective_time().is_some()
})
.count(),
"archive" => notes.iter().filter(|n| n.done).count(),
other => {
let nt = NoteType::from_str(other);
notes.iter().filter(|n| !n.done && n.note_type == nt).count()
}
};
label.set_label(&n.to_string());
}
} }
// ── main ───────────────────────────────────────────────────────────────────── // ── main ─────────────────────────────────────────────────────────────────────
@ -331,7 +378,12 @@ fn build_app_window(
)); ));
row row
}; };
let make_item = |id: &str, icon: &str, label: &str| { // Returns the row plus its count `Label` when `counted` is set — callers
// collect these so counts can be kept live from `AppState.sidebar_counts`
// (monochrome geometric icons + a colored dot per type instead of the
// old full-color emoji, which always render in fixed colors no matter
// what the pywal palette says).
let make_item = |id: &str, icon: &str, icon_class: Option<&str>, label: &str, counted: bool| {
let row = gtk4::ListBoxRow::builder() let row = gtk4::ListBoxRow::builder()
.css_classes(["sidebar-row"]) .css_classes(["sidebar-row"])
.build(); .build();
@ -340,13 +392,15 @@ fn build_app_window(
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
.spacing(10) .spacing(10)
.build(); .build();
hbox.append( let icon_label = gtk4::Label::builder()
&gtk4::Label::builder() .label(icon)
.label(icon) .width_chars(2)
.width_chars(2) .xalign(0.5)
.xalign(0.5) .build();
.build(), if let Some(class) = icon_class {
); icon_label.add_css_class(class);
}
hbox.append(&icon_label);
hbox.append( hbox.append(
&gtk4::Label::builder() &gtk4::Label::builder()
.label(label) .label(label)
@ -354,23 +408,62 @@ fn build_app_window(
.hexpand(true) .hexpand(true)
.build(), .build(),
); );
let count_label = if counted {
let l = gtk4::Label::builder()
.label("")
.css_classes(["sidebar-count"])
.build();
hbox.append(&l);
Some(l)
} else {
None
};
row.set_child(Some(&hbox)); row.set_child(Some(&hbox));
row (row, count_label)
}; };
let mut sidebar_counts: Vec<(String, gtk4::Label)> = Vec::new();
sidebar_list.append(&make_section("VIEWS")); sidebar_list.append(&make_section("VIEWS"));
sidebar_list.append(&make_item("all", "📋", "All")); {
sidebar_list.append(&make_item("upcoming", "📅", "Upcoming")); let (row, count) = make_item("all", "", None, "All", true);
sidebar_counts.push(("all".into(), count.unwrap()));
sidebar_list.append(&row);
}
{
let (row, count) = make_item("upcoming", "", None, "Upcoming", true);
sidebar_counts.push(("upcoming".into(), count.unwrap()));
sidebar_list.append(&row);
}
sidebar_list.append(&make_section("TYPES")); sidebar_list.append(&make_section("TYPES"));
sidebar_list.append(&make_item("todo", "", "Todo")); for (id, icon_class, label) in [
sidebar_list.append(&make_item("reminder", "🔔", "Reminder")); ("todo", "icon-todo", "Todo"),
sidebar_list.append(&make_item("idea", "💡", "Idea")); ("reminder", "icon-reminder", "Reminder"),
sidebar_list.append(&make_item("note", "📝", "Note")); ("idea", "icon-idea", "Idea"),
sidebar_list.append(&make_item("question", "", "Question")); ("note", "icon-note", "Note"),
("question", "icon-question", "Question"),
] {
let (row, count) = make_item(id, "", Some(icon_class), label, true);
sidebar_counts.push((id.to_string(), count.unwrap()));
sidebar_list.append(&row);
}
sidebar_list.append(&make_section("MORE")); sidebar_list.append(&make_section("MORE"));
sidebar_list.append(&make_item("archive", "📦", "Archive")); {
sidebar_list.append(&make_item("settings", "", "Settings")); let (row, count) = make_item("archive", "", None, "Archive", true);
sidebar_list.append(&make_item("errors", "", "Errors")); sidebar_counts.push(("archive".into(), count.unwrap()));
sidebar_list.append(&row);
}
{
let (row, _) = make_item("settings", "", None, "Settings", false);
sidebar_list.append(&row);
}
{
// De-emphasized: session-only debug info, not part of the daily
// triage flow the rest of the sidebar serves.
let (row, _) = make_item("errors", "", None, "Errors", false);
row.add_css_class("sidebar-row-minor");
sidebar_list.append(&row);
}
sidebar_vbox.append(&sidebar_list); sidebar_vbox.append(&sidebar_list);
// ── Content area ────────────────────────────────────────────── // ── Content area ──────────────────────────────────────────────
@ -390,8 +483,19 @@ fn build_app_window(
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build(); let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
let status_label = gtk4::Label::builder()
.label("0 notes")
.css_classes(["dim-label"])
.xalign(0.0)
.margin_start(12)
.margin_end(12)
.margin_top(6)
.margin_bottom(8)
.build();
content_vbox.append(&search_entry); content_vbox.append(&search_entry);
content_vbox.append(&stack); content_vbox.append(&stack);
content_vbox.append(&status_label);
hbox.append(&sidebar_vbox); hbox.append(&sidebar_vbox);
hbox.append(&gtk4::Separator::builder() hbox.append(&gtk4::Separator::builder()
@ -401,7 +505,8 @@ fn build_app_window(
window.set_child(Some(&hbox)); window.set_child(Some(&hbox));
// ── AppState ────────────────────────────────────────────────── // ── AppState ──────────────────────────────────────────────────
let state = AppState::new(store, notes, cfg, stack.clone()); let state = AppState::new(store, notes, cfg, stack.clone(), status_label.clone());
state.sidebar_counts.replace(sidebar_counts);
// Initial build // Initial build
rebuild_stack(&state); rebuild_stack(&state);
@ -478,6 +583,9 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
.vexpand(true) .vexpand(true)
.build(); .build();
// Capped and centered so cards don't stretch edge-to-edge on a wide
// window — full-width rows left the type chip and action buttons
// hundreds of pixels from the title they belong to.
let list = gtk4::Box::builder() let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
.spacing(8) .spacing(8)
@ -485,18 +593,33 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
.margin_bottom(12) .margin_bottom(12)
.margin_start(12) .margin_start(12)
.margin_end(12) .margin_end(12)
.width_request(700)
.halign(gtk4::Align::Center)
.build(); .build();
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect(); let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
sorted.sort_by(|a, b| b.created.cmp(&a.created)); sorted.sort_by(|a, b| b.created.cmp(&a.created));
if sorted.is_empty() { if sorted.is_empty() {
list.append( let empty = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(6)
.halign(gtk4::Align::Center)
.margin_top(64)
.build();
empty.append(
&gtk4::Label::builder() &gtk4::Label::builder()
.label("No notes here yet.") .label("No notes here yet")
.margin_top(32) .css_classes(["note-title"])
.build(), .build(),
); );
empty.append(
&gtk4::Label::builder()
.label("Capture something with breadpad and it'll show up here.")
.css_classes(["dim-label"])
.build(),
);
list.append(&empty);
} else { } else {
for note in &sorted { for note in &sorted {
list.append(&build_note_card(note, state.clone())); list.append(&build_note_card(note, state.clone()));
@ -507,6 +630,26 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
scroll scroll
} }
/// Short relative form for a recency-ordered list ("2h ago"); the exact
/// absolute timestamp is still available via tooltip. Falls back to a plain
/// date once a note is more than a week old, where "Nd ago" stops being
/// useful at a glance.
fn humanize_relative(dt: DateTime<Utc>) -> String {
let secs = Utc::now().signed_duration_since(dt).num_seconds().max(0);
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else if secs < 86_400 * 7 {
format!("{}d ago", secs / 86_400)
} else {
let local: DateTime<Local> = dt.into();
local.format("%b %d").to_string()
}
}
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
let card = gtk4::Box::builder() let card = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
@ -530,6 +673,7 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
.hexpand(true) .hexpand(true)
.xalign(0.0) .xalign(0.0)
.wrap(true) .wrap(true)
.css_classes(["note-title"])
.build(); .build();
let type_chip = gtk4::Label::builder() let type_chip = gtk4::Label::builder()
@ -546,14 +690,15 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
.spacing(8) .spacing(8)
.build(); .build();
let created_str = { let created_abs = {
let local: chrono::DateTime<Local> = note.created.into(); let local: chrono::DateTime<Local> = note.created.into();
local.format("%b %d %H:%M").to_string() local.format("%b %d %H:%M").to_string()
}; };
let meta_label = gtk4::Label::builder() let meta_label = gtk4::Label::builder()
.label(&created_str) .label(&humanize_relative(note.created))
.css_classes(["dim-label"]) .css_classes(["dim-label"])
.xalign(0.0) .xalign(0.0)
.tooltip_text(&created_abs)
.build(); .build();
// Date first, then chips // Date first, then chips
@ -561,8 +706,9 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
if let Some(ws) = &note.workspace { if let Some(ws) = &note.workspace {
bottom_row.append( bottom_row.append(
&gtk4::Label::builder() &gtk4::Label::builder()
.label(&format!("ws:{}", ws)) .label(&format!("ws {}", ws))
.css_classes(["type-chip"]) .css_classes(["type-chip"])
.tooltip_text(&format!("Workspace {}", ws))
.build(), .build(),
); );
} }

View file

@ -85,6 +85,43 @@ fn rrule_weekday(wd: Weekday) -> &'static str {
} }
} }
/// Explicit type prefixes, checked before any lexical heuristics. Short forms
/// exist so the capture popup can be driven without reaching for the mouse —
/// see `breadpad_shared::parser::detect_prefix_type`, which the popup's entry
/// uses to live-highlight the matching chip as the user types.
const TYPE_PREFIXES: &[(&str, NoteType)] = &[
("td:", NoteType::Todo),
("rem:", NoteType::Reminder),
("idea:", NoteType::Idea),
("note:", NoteType::Note),
("q:", NoteType::Question),
];
/// If `text` starts with one of [`TYPE_PREFIXES`] (case-insensitive), returns
/// the type it forces. Used both to classify at save time and to live-drive
/// the popup's chip highlighting as the user types.
pub fn detect_prefix_type(text: &str) -> Option<NoteType> {
let lower = text.trim_start().to_lowercase();
TYPE_PREFIXES
.iter()
.find(|(prefix, _)| lower.starts_with(prefix))
.map(|(_, nt)| nt.clone())
}
/// Strips a leading explicit type prefix (if any), returning the forced type
/// and the remaining text with the prefix and any following whitespace removed.
fn strip_explicit_prefix(text: &str) -> (Option<NoteType>, String) {
let trimmed = text.trim_start();
let lower = trimmed.to_lowercase();
for (prefix, nt) in TYPE_PREFIXES {
if lower.starts_with(prefix) {
let rest = trimmed[prefix.len()..].trim_start().to_string();
return (Some(nt.clone()), rest);
}
}
(None, text.to_string())
}
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> { fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
let local = Local::now(); let local = Local::now();
let days_ahead = (wd.num_days_from_monday() as i64 let days_ahead = (wd.num_days_from_monday() as i64
@ -105,6 +142,8 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
} }
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult { pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
let (forced_type, text_owned) = strip_explicit_prefix(text);
let text: &str = &text_owned;
let p = patterns(); let p = patterns();
let morning_time: NaiveTime = default_morning let morning_time: NaiveTime = default_morning
.split(':') .split(':')
@ -290,8 +329,9 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
.to_string(); .to_string();
} }
// Infer note type // Infer note type — an explicit prefix (`td:`, `rem:`, …) always wins.
let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some()); let note_type =
forced_type.clone().unwrap_or_else(|| infer_type(text, extracted_time.is_some(), rrule.is_some()));
// Trim artifacts // Trim artifacts
cleaned = cleaned cleaned = cleaned
@ -303,7 +343,9 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
// Calibrated confidence: high when structural signals drove the decision, // Calibrated confidence: high when structural signals drove the decision,
// low when we fell back to "note" with no positive evidence. // low when we fell back to "note" with no positive evidence.
let confidence = if rrule.is_some() || extracted_time.is_some() { let confidence = if forced_type.is_some() {
0.99 // explicit prefix — unambiguous
} else if rrule.is_some() || extracted_time.is_some() {
0.95 // time/recurrence extraction is deterministic 0.95 // time/recurrence extraction is deterministic
} else { } else {
match &note_type { match &note_type {
@ -410,6 +452,70 @@ mod tests {
assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea); assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea);
} }
#[test]
fn idea_prefix_stripped_from_body() {
let r = p("idea: reactive state module in Lua");
assert_eq!(r.body, "reactive state module in Lua");
}
// ---- Explicit short prefixes (td:, rem:, note:, q:) ----
#[test]
fn td_prefix_is_todo() {
let r = p("td: buy milk");
assert_eq!(r.note_type, NoteType::Todo);
assert_eq!(r.body, "buy milk");
}
#[test]
fn rem_prefix_is_reminder() {
let r = p("rem: water the plants");
assert_eq!(r.note_type, NoteType::Reminder);
assert_eq!(r.body, "water the plants");
}
#[test]
fn note_prefix_is_note() {
// Without the prefix this would classify as Todo ("check ...").
let r = p("note: check engine light has been on for a week");
assert_eq!(r.note_type, NoteType::Note);
assert_eq!(r.body, "check engine light has been on for a week");
}
#[test]
fn q_prefix_is_question() {
// Without the prefix this has no strong signal and would fall to Note.
let r = p("q: ONNX rocm vs cpu perf");
assert_eq!(r.note_type, NoteType::Question);
assert_eq!(r.body, "ONNX rocm vs cpu perf");
}
#[test]
fn explicit_prefix_confidence_is_high() {
assert_eq!(p("td: buy milk").confidence, 0.99);
}
#[test]
fn explicit_prefix_is_case_insensitive() {
assert_eq!(p("TD: buy milk").note_type, NoteType::Todo);
assert_eq!(p("Rem: standup").note_type, NoteType::Reminder);
}
#[test]
fn explicit_prefix_overrides_time_extraction_type() {
// "at 7pm" alone would infer Reminder; an explicit td: prefix wins.
let r = p("td: pack bag at 7pm");
assert_eq!(r.note_type, NoteType::Todo);
assert!(r.time.is_some(), "time should still be extracted");
}
#[test]
fn detect_prefix_type_matches_parse() {
assert_eq!(detect_prefix_type("td: buy milk"), Some(NoteType::Todo));
assert_eq!(detect_prefix_type("rem: call mum"), Some(NoteType::Reminder));
assert_eq!(detect_prefix_type("no prefix here"), None);
}
#[test] #[test]
fn idea_maybe() { fn idea_maybe() {
assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea); assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea);
@ -884,7 +990,6 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
return NoteType::Todo; return NoteType::Todo;
} }
if lower.starts_with("what if ") if lower.starts_with("what if ")
|| lower.starts_with("idea:")
|| lower.contains("could ") || lower.contains("could ")
|| lower.contains("maybe ") || lower.contains("maybe ")
|| lower.starts_with("should we ") || lower.starts_with("should we ")

View file

@ -43,9 +43,14 @@ window { border-radius: 8px; }
border-color: @teal; border-color: @teal;
} }
/* Shared "selected/active" language: a ghost/ outline default state that
stays quiet, and a solid, high-contrast accent fill for whatever is
currently selected used identically by .type-chip.active and
.sidebar-row:selected so the two windows read as one system. */
.type-chip { .type-chip {
background: @overlay; background: transparent;
color: @on-overlay; color: alpha(@fg, 0.6);
border: 1px solid alpha(@fg, 0.18);
border-radius: 999px; border-radius: 999px;
padding: 4px 12px; padding: 4px 12px;
font-size: 12px; font-size: 12px;
@ -55,27 +60,69 @@ window { border-radius: 8px; }
.type-chip.active { .type-chip.active {
background: @blue; background: @blue;
color: @on-accent; color: @on-accent;
border-color: @blue;
font-weight: 600;
} }
/* Per-type tint so info badges (note cards, workspace/recur tags) stay
scannable at a glance without the old full-color emoji. */
.note-card-todo .type-chip { color: @green; border-color: alpha(@green, 0.4); }
.note-card-reminder .type-chip { color: @yellow; border-color: alpha(@yellow, 0.4); }
.note-card-idea .type-chip { color: @pink; border-color: alpha(@pink, 0.4); }
.note-card-question .type-chip { color: @teal; border-color: alpha(@teal, 0.4); }
.note-card-note .type-chip { color: @blue; border-color: alpha(@blue, 0.4); }
.confirm-button { .confirm-button {
background: @blue; background: @blue;
color: @on-accent; color: @on-accent;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 8px 16px; padding: 10px 22px;
min-height: 20px;
font-size: 15px;
font-weight: bold; font-weight: bold;
} }
.confirm-button:hover { background: shade(@blue, 1.1); }
/* Separates the primary action from the chip row it sits beside so it
doesn't read as just another pill. */
.confirm-wrap {
border-left: 1px solid alpha(@fg, 0.12);
padding-left: 12px;
margin-left: 4px;
}
.prefix-hint {
color: alpha(@fg, 0.4);
font-size: 10px;
letter-spacing: 0.3px;
}
.note-card { .note-card {
background: shade(@bg, 1.1); background: shade(@bg, 1.12);
border-radius: 8px; border: 1px solid alpha(@fg, 0.07);
border-radius: 10px;
padding: 12px; padding: 12px;
margin: 8px; margin: 6px 0;
border-left: 3px solid @blue; border-left: 3px solid @blue;
} }
.note-card:hover { .note-card:hover {
background: shade(@bg, 1.2); background: shade(@bg, 1.22);
border-color: alpha(@fg, 0.12);
}
.note-title {
font-weight: 600;
}
.note-card .action-btn {
opacity: 0.45;
}
.note-card:hover .action-btn {
opacity: 1;
} }
.search-entry { .search-entry {
@ -83,7 +130,8 @@ window { border-radius: 8px; }
color: @fg; color: @fg;
border: 1px solid @overlay; border: 1px solid @overlay;
border-radius: 6px; border-radius: 6px;
padding: 8px 12px; padding: 5px 10px;
font-size: 13px;
} }
.search-entry:focus { .search-entry:focus {
@ -104,7 +152,27 @@ window { border-radius: 8px; }
.sidebar-row:selected { .sidebar-row:selected {
background: @blue; background: @blue;
color: @on-accent; color: @on-accent;
font-weight: 500; font-weight: 600;
}
.sidebar-row-minor {
opacity: 0.5;
font-size: 12px;
}
.sidebar-count {
color: alpha(@fg, 0.45);
font-size: 11px;
}
.icon-todo { color: @green; }
.icon-reminder { color: @yellow; }
.icon-idea { color: @pink; }
.icon-note { color: @blue; }
.icon-question { color: @teal; }
.sidebar-row:selected .sidebar-count {
color: alpha(@on-accent, 0.75);
} }
.sidebar-section-label { .sidebar-section-label {
@ -119,14 +187,15 @@ window { border-radius: 8px; }
background: transparent; background: transparent;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
padding: 2px 7px; padding: 3px 8px;
min-width: 28px; min-width: 32px;
min-height: 28px; min-height: 32px;
font-size: 14px; font-size: 16px;
} }
.action-btn:hover { .action-btn:hover {
background: shade(@bg, 1.3); background: shade(@bg, 1.3);
opacity: 1;
} }
.done-btn { color: @green; } .done-btn { color: @green; }

View file

@ -636,11 +636,43 @@ fn build_window(
} }
} }
// Live prefix grammar: typing "td: ", "rem: ", "idea: ", "note: ", or
// "q: " at the start of the entry drives the chip selection without
// ever touching the mouse — the chips become feedback for what you
// typed rather than the only way to pick a type.
{
let selected_type_clone = selected_type.clone();
let chips_clone: Vec<(gtk4::Button, NoteType)> = chips.clone();
entry.connect_changed(move |e| {
let Some(nt) = breadpad_shared::parser::detect_prefix_type(&e.text()) else {
return;
};
*selected_type_clone.borrow_mut() = nt.clone();
for (btn, chip_nt) in &chips_clone {
if *chip_nt == nt {
btn.add_css_class("active");
} else {
btn.remove_css_class("active");
}
}
});
}
let hint = gtk4::Label::builder()
.label("td: · rem: · idea: · note: · q:")
.css_classes(["prefix-hint"])
.xalign(0.0)
.build();
// Confirm button // Confirm button
let confirm_btn = gtk4::Button::builder() let confirm_btn = gtk4::Button::builder()
.label("") .label("")
.css_classes(["confirm-button"]) .css_classes(["confirm-button"])
.build(); .build();
let confirm_wrap = gtk4::Box::builder()
.css_classes(["confirm-wrap"])
.build();
confirm_wrap.append(&confirm_btn);
let bottom_row = gtk4::Box::builder() let bottom_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal) .orientation(gtk4::Orientation::Horizontal)
@ -650,9 +682,10 @@ fn build_window(
let spacer = gtk4::Box::builder().hexpand(true).build(); let spacer = gtk4::Box::builder().hexpand(true).build();
bottom_row.append(&spacer); bottom_row.append(&spacer);
bottom_row.append(&confirm_btn); bottom_row.append(&confirm_wrap);
vbox.append(&entry); vbox.append(&entry);
vbox.append(&hint);
vbox.append(&bottom_row); vbox.append(&bottom_row);
window.set_child(Some(&vbox)); window.set_child(Some(&vbox));