Personal outliner built with Rust.
3

Configure Feed

Select the types of activity you want to include in your feed.

8.1 dogfood fixes: journal rollover timer, calendar picker, mouse nav

First real dogfooding feedback:
- new daily journal page silently required an app restart to appear: check_journal_rollover only ran as a side effect of render(), and GPUI doesn't repaint on a frame timer, only in response to input, so a window left open past midnight never noticed. Added a self-rescheduling background timer (60s) independent of interaction.
- added a calendar picker (Ctrl+Shift+C or the header button): month grid, click a day to jump to/create its journal page, bolds days with existing entries, highlights today. Also serves as a manual escape hatch for #1.
- added clickable back/forward buttons in the header for mouse-driven navigation, alongside the existing Alt+Left/Alt+Right

graham.systems (Jul 12, 2026, 2:52 PM -0700) a1f2a23b 969eab13

+281 -13
+7
README.md
··· 105 105 | `Ctrl+.` / `Ctrl+,` | Zoom into / out of the focused block | 106 106 | `Ctrl+Enter` | Create a page from an uncreated tag/page/date view | 107 107 | `Ctrl+Shift+Q` | Toggle the focused block as a query block | 108 + | `Ctrl+Shift+C` | Open the calendar picker | 109 + 110 + Back/forward and the calendar are also available as clickable buttons in the 111 + header (◀ / ▶ / "Calendar") for mouse-driven navigation — the calendar opens 112 + to the current month; click a day to jump to (or create) its journal page, 113 + click ◀/▶ in the calendar itself to change months. Days that already have a 114 + journal page are bolded; today is highlighted. 108 115 109 116 **While editing a block:** 110 117
+274 -13
crates/trawler/src/main.rs
··· 38 38 use std::sync::Arc; 39 39 use std::time::Duration; 40 40 41 - use chrono::NaiveDate; 41 + use chrono::{Datelike, NaiveDate}; 42 42 use editor::{BlockEditor, BlockEditorEvent, CompletionCandidate}; 43 43 use gpui::{ 44 - actions, deferred, div, list, point, px, rgb, size, App, AppContext as _, Application, Bounds, 45 - ClickEvent, Context, Entity, FocusHandle, Focusable as _, FontWeight, InteractiveElement as _, 46 - IntoElement, KeyBinding, KeyDownEvent, ListAlignment, ListState, MouseButton, ParentElement, 47 - Render, StatefulInteractiveElement as _, Styled, Subscription, Window, WindowBounds, 48 - WindowOptions, 44 + actions, deferred, div, list, point, px, relative, rgb, size, App, AppContext as _, 45 + Application, Bounds, ClickEvent, Context, Entity, FocusHandle, Focusable as _, FontWeight, 46 + InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, ListAlignment, ListState, 47 + MouseButton, ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription, 48 + Window, WindowBounds, WindowOptions, 49 49 }; 50 50 use loro::TreeID; 51 51 use markdown::{Block, Inline}; ··· 67 67 QuickOpen, 68 68 CreatePage, 69 69 SearchOpen, 70 - ToggleQueryBlock 70 + ToggleQueryBlock, 71 + ToggleCalendar 71 72 ] 72 73 ); 73 74 ··· 91 92 // whether a block is a query is graph state, not a text edit — 92 93 // toggling it doesn't belong in the editor's own key table. 93 94 KeyBinding::new("ctrl-shift-q", ToggleQueryBlock, Some(APP_CONTEXT)), 95 + KeyBinding::new("ctrl-shift-c", ToggleCalendar, Some(APP_CONTEXT)), 94 96 ]); 95 97 } 96 98 ··· 162 164 selected: usize, 163 165 } 164 166 167 + /// State for the calendar picker: which month is currently displayed. 168 + /// Always the first of that month, for easy prev/next-month arithmetic. 169 + struct CalendarState { 170 + month: NaiveDate, 171 + } 172 + 165 173 struct TrawlerApp { 166 174 graph_dir: PathBuf, 167 175 storage: GraphStorage, ··· 213 221 /// Dedicated focus target for the search query field — same reasoning 214 222 /// as `quick_open_focus`. 215 223 search_focus: FocusHandle, 224 + /// The calendar picker, for jumping straight to (or creating) an 225 + /// arbitrary day's journal page by clicking a date — quicker than 226 + /// hunting for a `[[date]]` reference to follow, and doubles as a 227 + /// manual way to reach today's page if the automatic rollover timer 228 + /// ever misses it. 229 + calendar_open: Option<CalendarState>, 216 230 /// Blocks lexically similar to the currently focused block (spec: 217 231 /// "Similar blocks (lexical)"), refreshed on every focus change. 218 232 /// Shown as a dedicated panel rather than an inline footer on the ··· 245 259 /// Sort state for a table-shaped query result: (column index, 246 260 /// ascending). Absent = unsorted (result order as returned). 247 261 table_sort: HashMap<TreeID, (usize, bool)>, 248 - /// The local date as of the last journal refresh, checked on every 249 - /// render so a rollover while the app stays open picks up a new 250 - /// today's page without needing a restart or an edit to trigger it 251 - /// (spec: journal/"Date rollover"). 262 + /// The local date as of the last journal refresh. Checked on every 263 + /// render (interaction picks it up immediately) *and* by a 264 + /// self-rescheduling background timer (see 265 + /// `spawn_journal_rollover_timer`) so a rollover while the window is 266 + /// left open and untouched — GPUI doesn't repaint on a frame timer, 267 + /// only in response to input — still surfaces without needing a 268 + /// restart (spec: journal/"Date rollover"). 252 269 journal_today: NaiveDate, 253 270 /// This window's handle, stashed so event-emitter callbacks (which 254 271 /// only get `&mut Context<Self>`, no `&mut Window`) can hop back into ··· 298 315 search, 299 316 search_open: None, 300 317 search_focus: cx.focus_handle(), 318 + calendar_open: None, 301 319 similar: Vec::new(), 302 320 query_results: HashMap::new(), 303 321 query_pending: HashSet::new(), ··· 315 333 if let Some(block) = first_block.or(Some(today_page)) { 316 334 app.focus_block(block, window, cx); 317 335 } 336 + app.spawn_journal_rollover_timer(cx); 318 337 app 338 + } 339 + 340 + /// `check_journal_rollover` only runs as a side effect of `render()` — 341 + /// fine while the user is actively clicking/typing, but a window that 342 + /// sits open and untouched past midnight (GPUI only repaints in 343 + /// response to input, not on a frame timer) would never call `render` 344 + /// again to notice the date changed, leaving today's page missing 345 + /// until the app was restarted. This spawns a self-rescheduling 346 + /// background check independent of any user interaction, so a 347 + /// rollover is caught within a minute of actually happening. 348 + fn spawn_journal_rollover_timer(&self, cx: &mut Context<Self>) { 349 + cx.spawn(async move |this, cx| loop { 350 + cx.background_executor() 351 + .timer(Duration::from_secs(60)) 352 + .await; 353 + let Ok(()) = this.update(cx, |this, cx| this.check_journal_rollover(cx)) else { 354 + return; 355 + }; 356 + }) 357 + .detach(); 319 358 } 320 359 321 360 fn refresh_rows(&mut self) { ··· 705 744 page = parent; 706 745 } 707 746 self.navigate_to(View::Node(NodeId::tree(page)), Some(hit.block), window, cx); 747 + } 748 + 749 + /// Ctrl+Shift+C / the "Calendar" button: open (or close, if already 750 + /// open) the calendar picker, defaulting to the current month. 751 + fn toggle_calendar( 752 + &mut self, 753 + _: &ToggleCalendar, 754 + _window: &mut Window, 755 + cx: &mut Context<Self>, 756 + ) { 757 + if self.calendar_open.is_some() { 758 + self.calendar_open = None; 759 + } else { 760 + let today = chrono::Local::now().date_naive(); 761 + self.calendar_open = Some(CalendarState { 762 + month: NaiveDate::from_ymd_opt(today.year(), today.month(), 1) 763 + .expect("today's year/month is always a valid date"), 764 + }); 765 + } 766 + cx.notify(); 767 + } 768 + 769 + /// Move the calendar's displayed month by `delta` (±1). 770 + fn shift_calendar_month(&mut self, delta: i32, cx: &mut Context<Self>) { 771 + let Some(state) = self.calendar_open.as_mut() else { 772 + return; 773 + }; 774 + let total_months = state.month.year() * 12 + state.month.month0() as i32 + delta; 775 + let (year, month0) = (total_months.div_euclid(12), total_months.rem_euclid(12)); 776 + state.month = NaiveDate::from_ymd_opt(year, month0 as u32 + 1, 1) 777 + .expect("computed year/month is always in range"); 778 + cx.notify(); 779 + } 780 + 781 + /// Click a calendar day: jump to (creating if necessary) that day's 782 + /// journal page, same as following a `[[date]]` reference. 783 + fn navigate_to_calendar_day( 784 + &mut self, 785 + date: NaiveDate, 786 + window: &mut Window, 787 + cx: &mut Context<Self>, 788 + ) { 789 + let page = ensure_today_journal_page(&self.storage, date); 790 + // Rare event (a deliberate date jump, not per-keystroke) — a full 791 + // rebuild is simpler than precisely tracking what changed. 792 + let _ = self.search.rebuild(self.storage.doc()); 793 + self.calendar_open = None; 794 + self.navigate_to(View::Node(NodeId::tree(page)), None, window, cx); 708 795 } 709 796 710 797 /// Resolve a date to the node it should actually navigate to: that ··· 1732 1819 .into_any_element() 1733 1820 } 1734 1821 1822 + /// The calendar picker: a month grid, click a day to jump to (creating if 1823 + /// necessary) its journal page. Days that already have a journal page are 1824 + /// bolded; today is highlighted. 1825 + fn render_calendar( 1826 + state: &CalendarState, 1827 + outline: &Outline, 1828 + cx: &mut Context<TrawlerApp>, 1829 + ) -> gpui::AnyElement { 1830 + let month = state.month; 1831 + let today = chrono::Local::now().date_naive(); 1832 + 1833 + let existing_dates: HashSet<NaiveDate> = outline 1834 + .children(None) 1835 + .into_iter() 1836 + .filter_map(|page| journal_page_date(outline, page)) 1837 + .collect(); 1838 + 1839 + let next_month_first = if month.month() == 12 { 1840 + NaiveDate::from_ymd_opt(month.year() + 1, 1, 1) 1841 + } else { 1842 + NaiveDate::from_ymd_opt(month.year(), month.month() + 1, 1) 1843 + } 1844 + .expect("computed year/month is always in range"); 1845 + let days_in_month = (next_month_first - month).num_days(); 1846 + let leading_blanks = month.weekday().num_days_from_sunday(); 1847 + 1848 + let day_cell = |day: Option<u32>| -> gpui::AnyElement { 1849 + let base = div() 1850 + .w(relative(1.0 / 7.0)) 1851 + .h(px(28.0)) 1852 + .flex() 1853 + .items_center() 1854 + .justify_center() 1855 + .rounded_sm(); 1856 + let Some(day) = day else { 1857 + return base.into_any_element(); 1858 + }; 1859 + let date = month 1860 + .with_day(day) 1861 + .expect("day is within this month's range"); 1862 + let mut cell = base 1863 + .id(("calendar-day", day as usize)) 1864 + .cursor_pointer() 1865 + .hover(|d| d.bg(rgb(0x2a2a3a))) 1866 + .child(day.to_string()) 1867 + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1868 + this.navigate_to_calendar_day(date, window, cx); 1869 + })); 1870 + if date == today { 1871 + cell = cell.bg(rgb(QUERY_ACCENT_COLOR)).text_color(rgb(0x1e1e1e)); 1872 + } else if existing_dates.contains(&date) { 1873 + cell = cell.font_weight(FontWeight::BOLD); 1874 + } 1875 + cell.into_any_element() 1876 + }; 1877 + 1878 + let mut cells: Vec<_> = (0..leading_blanks).map(|_| day_cell(None)).collect(); 1879 + cells.extend((1..=days_in_month as u32).map(|day| day_cell(Some(day)))); 1880 + 1881 + deferred( 1882 + gpui::anchored().position(point(px(220.0), px(70.0))).child( 1883 + div() 1884 + .id("calendar") 1885 + .w(px(280.0)) 1886 + .bg(rgb(0x252525)) 1887 + .border_1() 1888 + .border_color(rgb(0x3a3a3a)) 1889 + .rounded_md() 1890 + .shadow_lg() 1891 + .flex() 1892 + .flex_col() 1893 + .gap_1() 1894 + .p_2() 1895 + .child( 1896 + div() 1897 + .flex() 1898 + .flex_row() 1899 + .items_center() 1900 + .justify_between() 1901 + .child( 1902 + div() 1903 + .id("calendar-prev") 1904 + .cursor_pointer() 1905 + .px_1() 1906 + .rounded_sm() 1907 + .hover(|d| d.bg(rgb(0x2a2a3a))) 1908 + .child("◀") 1909 + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { 1910 + this.shift_calendar_month(-1, cx); 1911 + })), 1912 + ) 1913 + .child( 1914 + div() 1915 + .font_weight(FontWeight::BOLD) 1916 + .child(month.format("%B %Y").to_string()), 1917 + ) 1918 + .child( 1919 + div() 1920 + .id("calendar-next") 1921 + .cursor_pointer() 1922 + .px_1() 1923 + .rounded_sm() 1924 + .hover(|d| d.bg(rgb(0x2a2a3a))) 1925 + .child("▶") 1926 + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { 1927 + this.shift_calendar_month(1, cx); 1928 + })), 1929 + ), 1930 + ) 1931 + .child(div().flex().flex_row().children( 1932 + ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].iter().map(|d| { 1933 + div() 1934 + .w(relative(1.0 / 7.0)) 1935 + .flex() 1936 + .justify_center() 1937 + .text_size(px(11.0)) 1938 + .text_color(rgb(MUTED_COLOR)) 1939 + .child(*d) 1940 + }), 1941 + )) 1942 + .child(div().flex().flex_row().flex_wrap().children(cells)), 1943 + ), 1944 + ) 1945 + .with_priority(2) 1946 + .into_any_element() 1947 + } 1948 + 1735 1949 fn render_snippet(html: &str) -> gpui::AnyElement { 1736 1950 let mut spans = Vec::new(); 1737 1951 let mut bold = false; ··· 2178 2392 .into_any_element() 2179 2393 }); 2180 2394 2395 + let calendar_overlay = self 2396 + .calendar_open 2397 + .as_ref() 2398 + .map(|state| render_calendar(state, &Outline::new(self.storage.doc()), cx)); 2399 + 2181 2400 div() 2182 2401 .size_full() 2183 2402 .flex() ··· 2189 2408 .on_action(cx.listener(Self::toggle_quick_open)) 2190 2409 .on_action(cx.listener(Self::toggle_search)) 2191 2410 .on_action(cx.listener(Self::toggle_query_block)) 2411 + .on_action(cx.listener(Self::toggle_calendar)) 2192 2412 .on_action(cx.listener(Self::create_page_for_view)) 2193 2413 .on_action(cx.listener(Self::zoom_in)) 2194 2414 .on_action(cx.listener(Self::zoom_out)) ··· 2199 2419 .p_2() 2200 2420 .border_b_1() 2201 2421 .border_color(rgb(0x3a3a3a)) 2202 - .child(format!( 2422 + .flex() 2423 + .flex_row() 2424 + .items_center() 2425 + .gap_2() 2426 + .child( 2427 + div() 2428 + .id("nav-back") 2429 + .cursor_pointer() 2430 + .px_1() 2431 + .rounded_sm() 2432 + .hover(|d| d.bg(rgb(0x2a2a3a))) 2433 + .child("◀") 2434 + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { 2435 + this.navigate_back(&NavigateBack, window, cx); 2436 + })), 2437 + ) 2438 + .child( 2439 + div() 2440 + .id("nav-forward") 2441 + .cursor_pointer() 2442 + .px_1() 2443 + .rounded_sm() 2444 + .hover(|d| d.bg(rgb(0x2a2a3a))) 2445 + .child("▶") 2446 + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { 2447 + this.navigate_forward(&NavigateForward, window, cx); 2448 + })), 2449 + ) 2450 + .child( 2451 + div() 2452 + .id("open-calendar") 2453 + .cursor_pointer() 2454 + .px_2() 2455 + .rounded_sm() 2456 + .hover(|d| d.bg(rgb(0x2a2a3a))) 2457 + .child("Calendar") 2458 + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { 2459 + this.toggle_calendar(&ToggleCalendar, window, cx); 2460 + })), 2461 + ) 2462 + .child(div().flex_1().child(format!( 2203 2463 "trawler — {title} — {} ({} blocks)", 2204 2464 self.graph_dir.display(), 2205 2465 row_count 2206 - )), 2466 + ))), 2207 2467 ) 2208 2468 .children(view_header) 2209 2469 .children(breadcrumb_bar) ··· 2408 2668 .children(similar_panel) 2409 2669 .children(quick_open_overlay) 2410 2670 .children(search_overlay) 2671 + .children(calendar_overlay) 2411 2672 } 2412 2673 } 2413 2674