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

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> {
let local = Local::now();
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 {
let (forced_type, text_owned) = strip_explicit_prefix(text);
let text: &str = &text_owned;
let p = patterns();
let morning_time: NaiveTime = default_morning
.split(':')
@ -290,8 +329,9 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
.to_string();
}
// Infer note type
let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some());
// Infer note type — an explicit prefix (`td:`, `rem:`, …) always wins.
let note_type =
forced_type.clone().unwrap_or_else(|| infer_type(text, extracted_time.is_some(), rrule.is_some()));
// Trim artifacts
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,
// 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
} else {
match &note_type {
@ -410,6 +452,70 @@ mod tests {
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]
fn idea_maybe() {
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;
}
if lower.starts_with("what if ")
|| lower.starts_with("idea:")
|| lower.contains("could ")
|| lower.contains("maybe ")
|| lower.starts_with("should we ")

View file

@ -43,9 +43,14 @@ window { border-radius: 8px; }
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 {
background: @overlay;
color: @on-overlay;
background: transparent;
color: alpha(@fg, 0.6);
border: 1px solid alpha(@fg, 0.18);
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
@ -55,27 +60,69 @@ window { border-radius: 8px; }
.type-chip.active {
background: @blue;
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 {
background: @blue;
color: @on-accent;
border: none;
border-radius: 8px;
padding: 8px 16px;
padding: 10px 22px;
min-height: 20px;
font-size: 15px;
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 {
background: shade(@bg, 1.1);
border-radius: 8px;
background: shade(@bg, 1.12);
border: 1px solid alpha(@fg, 0.07);
border-radius: 10px;
padding: 12px;
margin: 8px;
margin: 6px 0;
border-left: 3px solid @blue;
}
.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 {
@ -83,7 +130,8 @@ window { border-radius: 8px; }
color: @fg;
border: 1px solid @overlay;
border-radius: 6px;
padding: 8px 12px;
padding: 5px 10px;
font-size: 13px;
}
.search-entry:focus {
@ -104,7 +152,27 @@ window { border-radius: 8px; }
.sidebar-row:selected {
background: @blue;
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 {
@ -119,14 +187,15 @@ window { border-radius: 8px; }
background: transparent;
border: none;
border-radius: 6px;
padding: 2px 7px;
min-width: 28px;
min-height: 28px;
font-size: 14px;
padding: 3px 8px;
min-width: 32px;
min-height: 32px;
font-size: 16px;
}
.action-btn:hover {
background: shade(@bg, 1.3);
opacity: 1;
}
.done-btn { color: @green; }