From 6fea1af54492e86264b562604465bb3a5f6abb20 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 4 Jul 2026 10:53:52 +0800 Subject: [PATCH] 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. --- Cargo.lock | 8 +- Cargo.toml | 2 +- breadman/src/main.rs | 202 +++++++++++++++++++++++++++++----- breadpad-shared/src/parser.rs | 113 ++++++++++++++++++- breadpad-shared/src/theme.rs | 95 +++++++++++++--- breadpad/src/main.rs | 35 +++++- 6 files changed, 404 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 756f0c3..4936958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,7 +315,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "breadpad-shared", @@ -332,7 +332,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "breadpad-shared", @@ -351,7 +351,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "bread-theme", @@ -377,7 +377,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "breadpad-shared", diff --git a/Cargo.toml b/Cargo.toml index 4092bed..1de5996 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.4" +version = "0.4.0" edition = "2021" license = "MIT" authors = ["Breadway"] diff --git a/breadman/src/main.rs b/breadman/src/main.rs index d46557a..e132a80 100644 --- a/breadman/src/main.rs +++ b/breadman/src/main.rs @@ -6,7 +6,7 @@ use breadpad_shared::{ store::Store, types::{Note, NoteType, RecurrenceRule}, }; -use chrono::Local; +use chrono::{DateTime, Local, Utc}; use gtk4::{glib, prelude::*}; use std::cell::RefCell; use std::rc::Rc; @@ -69,10 +69,21 @@ struct AppState { errors: Rc, String)>>>, active_view: Rc>, 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>>, + status_label: gtk4::Label, } impl AppState { - fn new(store: Arc, notes: Vec, cfg: Config, stack: gtk4::Stack) -> Self { + fn new( + store: Arc, + notes: Vec, + cfg: Config, + stack: gtk4::Stack, + status_label: gtk4::Label, + ) -> Self { AppState { store, notes: Rc::new(RefCell::new(notes)), @@ -80,6 +91,8 @@ impl AppState { errors: Rc::new(RefCell::new(Vec::new())), active_view: Rc::new(RefCell::new("all".to_string())), stack, + sidebar_counts: Rc::new(RefCell::new(Vec::new())), + status_label, } } @@ -192,6 +205,40 @@ fn rebuild_stack(state: &AppState) { // Errors let errors_view = views::errors::build(&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 ───────────────────────────────────────────────────────────────────── @@ -331,7 +378,12 @@ fn build_app_window( )); 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() .css_classes(["sidebar-row"]) .build(); @@ -340,13 +392,15 @@ fn build_app_window( .orientation(gtk4::Orientation::Horizontal) .spacing(10) .build(); - hbox.append( - >k4::Label::builder() - .label(icon) - .width_chars(2) - .xalign(0.5) - .build(), - ); + let icon_label = gtk4::Label::builder() + .label(icon) + .width_chars(2) + .xalign(0.5) + .build(); + if let Some(class) = icon_class { + icon_label.add_css_class(class); + } + hbox.append(&icon_label); hbox.append( >k4::Label::builder() .label(label) @@ -354,23 +408,62 @@ fn build_app_window( .hexpand(true) .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 + (row, count_label) }; + let mut sidebar_counts: Vec<(String, gtk4::Label)> = Vec::new(); + 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_item("todo", "✅", "Todo")); - sidebar_list.append(&make_item("reminder", "🔔", "Reminder")); - sidebar_list.append(&make_item("idea", "💡", "Idea")); - sidebar_list.append(&make_item("note", "📝", "Note")); - sidebar_list.append(&make_item("question", "❓", "Question")); + for (id, icon_class, label) in [ + ("todo", "icon-todo", "Todo"), + ("reminder", "icon-reminder", "Reminder"), + ("idea", "icon-idea", "Idea"), + ("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_item("archive", "📦", "Archive")); - sidebar_list.append(&make_item("settings", "⚙", "Settings")); - sidebar_list.append(&make_item("errors", "⚠", "Errors")); + { + let (row, count) = make_item("archive", "▢", None, "Archive", true); + 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); // ── Content area ────────────────────────────────────────────── @@ -390,8 +483,19 @@ fn build_app_window( 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(&stack); + content_vbox.append(&status_label); hbox.append(&sidebar_vbox); hbox.append(>k4::Separator::builder() @@ -401,7 +505,8 @@ fn build_app_window( window.set_child(Some(&hbox)); // ── 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 rebuild_stack(&state); @@ -478,6 +583,9 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { .vexpand(true) .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() .orientation(gtk4::Orientation::Vertical) .spacing(8) @@ -485,18 +593,33 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { .margin_bottom(12) .margin_start(12) .margin_end(12) + .width_request(700) + .halign(gtk4::Align::Center) .build(); let mut sorted: Vec = notes.iter().filter(|n| !n.done).cloned().collect(); sorted.sort_by(|a, b| b.created.cmp(&a.created)); 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( >k4::Label::builder() - .label("No notes here yet.") - .margin_top(32) + .label("No notes here yet") + .css_classes(["note-title"]) .build(), ); + empty.append( + >k4::Label::builder() + .label("Capture something with breadpad and it'll show up here.") + .css_classes(["dim-label"]) + .build(), + ); + list.append(&empty); } else { for note in &sorted { list.append(&build_note_card(note, state.clone())); @@ -507,6 +630,26 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { 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) -> 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 = dt.into(); + local.format("%b %d").to_string() + } +} + fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { let card = gtk4::Box::builder() .orientation(gtk4::Orientation::Vertical) @@ -530,6 +673,7 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { .hexpand(true) .xalign(0.0) .wrap(true) + .css_classes(["note-title"]) .build(); let type_chip = gtk4::Label::builder() @@ -546,14 +690,15 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { .spacing(8) .build(); - let created_str = { + let created_abs = { let local: chrono::DateTime = note.created.into(); local.format("%b %d %H:%M").to_string() }; let meta_label = gtk4::Label::builder() - .label(&created_str) + .label(&humanize_relative(note.created)) .css_classes(["dim-label"]) .xalign(0.0) + .tooltip_text(&created_abs) .build(); // Date first, then chips @@ -561,8 +706,9 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { if let Some(ws) = ¬e.workspace { bottom_row.append( >k4::Label::builder() - .label(&format!("ws:{}", ws)) + .label(&format!("ws {}", ws)) .css_classes(["type-chip"]) + .tooltip_text(&format!("Workspace {}", ws)) .build(), ); } diff --git a/breadpad-shared/src/parser.rs b/breadpad-shared/src/parser.rs index cc03b5e..e398e73 100644 --- a/breadpad-shared/src/parser.rs +++ b/breadpad-shared/src/parser.rs @@ -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 { + 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, 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 { 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 { } 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 ") diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index cb1139a..8cc2512 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -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; } diff --git a/breadpad/src/main.rs b/breadpad/src/main.rs index aa924d0..033a114 100644 --- a/breadpad/src/main.rs +++ b/breadpad/src/main.rs @@ -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 let confirm_btn = gtk4::Button::builder() .label("✓") .css_classes(["confirm-button"]) .build(); + let confirm_wrap = gtk4::Box::builder() + .css_classes(["confirm-wrap"]) + .build(); + confirm_wrap.append(&confirm_btn); let bottom_row = gtk4::Box::builder() .orientation(gtk4::Orientation::Horizontal) @@ -650,9 +682,10 @@ fn build_window( let spacer = gtk4::Box::builder().hexpand(true).build(); bottom_row.append(&spacer); - bottom_row.append(&confirm_btn); + bottom_row.append(&confirm_wrap); vbox.append(&entry); + vbox.append(&hint); vbox.append(&bottom_row); window.set_child(Some(&vbox));