Release 0.4.0: GUI design pass + td:/rem:/note:/q: prefix grammar
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:
parent
b828f8ec79
commit
6fea1af544
6 changed files with 404 additions and 51 deletions
|
|
@ -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 ¬e_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 ")
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue