diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index 7f1926a..e2bc3c6 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -1376,14 +1376,40 @@ impl TrawlerApp { self.navigate_to(View::Journal, None, window, cx); } - /// The quick-open list body — query line plus result rows — shared by - /// the floating ctrl-k switcher and the Settings home picker's inline - /// dropdown. An associated fn (not `&self`) so callers can hold + /// The live query text with a caret bar after it — the visible signal + /// that a picker is type-to-refine. An empty query shows a muted + /// placeholder behind the caret instead. + fn render_query_line(query: &str) -> gpui::Div { + let caret = div() + .w(px(2.0)) + .h(px(16.0)) + .flex_none() + .bg(rgb(THREAD_COLOR)); + let line = div().flex().flex_row().items_center().min_w_0(); + if query.is_empty() { + line.child(caret).child( + div() + .pl_1() + .text_color(rgb(MUTED_COLOR)) + .child("Search pages and tags…"), + ) + } else { + line.child(div().child(query.to_string())).child(caret) + } + } + + /// The quick-open result list, shared by the floating ctrl-k switcher + /// and the Settings home combobox. `focus` attaches key handling here + /// (the switcher); the combobox passes `None` and carries focus on its + /// own input control instead. `show_query` adds the header query line + /// (again the switcher — the combobox shows the query in the control). + /// An associated fn (not `&self`) so callers can hold /// `self.quick_open` borrowed while building it. fn render_quick_open_body( state: &QuickOpenState, width: f32, - focus: &FocusHandle, + focus: Option<&FocusHandle>, + show_query: bool, cx: &mut Context, ) -> gpui::Stateful { let purpose = state.purpose; @@ -1416,10 +1442,8 @@ impl TrawlerApp { } item }); - div() + let mut body = div() .id("quick-open") - .track_focus(focus) - .on_key_down(cx.listener(Self::quick_open_key_down)) .w(px(width)) .max_h(px(320.0)) .overflow_hidden() @@ -1429,16 +1453,23 @@ impl TrawlerApp { .border_1() .border_color(rgb(0x3a3a3a)) .rounded_md() - .shadow_lg() - .child( + .shadow_lg(); + if let Some(focus) = focus { + body = body + .track_focus(focus) + .on_key_down(cx.listener(Self::quick_open_key_down)); + } + if show_query { + body = body.child( div() .px_2() .py_1() .border_b_1() .border_color(rgb(0x3a3a3a)) - .child(format!("> {}", state.query)), - ) - .children(items) + .child(Self::render_query_line(&state.query)), + ); + } + body.children(items) } /// The Settings screen body (spec: home-document/"Settings screen"), @@ -1501,9 +1532,57 @@ impl TrawlerApp { .quick_open .as_ref() .filter(|s| s.purpose == QuickOpenPurpose::PickHome) - .map(|state| { - Self::render_quick_open_body(state, HOME_SELECT_WIDTH, &self.quick_open_focus, cx) - }); + .map(|state| Self::render_quick_open_body(state, HOME_SELECT_WIDTH, None, false, cx)); + // Closed: a select showing the current value. Open: a combobox — + // the control itself becomes the text field (caret bar, muted + // placeholder, the typed query in place of the value) so + // type-to-refine is visibly on offer, and the dropdown below is + // purely the result list. + let control_content: gpui::AnyElement = if picker_open { + let query = self + .quick_open + .as_ref() + .map(|s| s.query.clone()) + .unwrap_or_default(); + Self::render_query_line(&query).into_any_element() + } else { + div().child(home_label).into_any_element() + }; + let mut control = div() + .id("home-picker") + .w(px(HOME_SELECT_WIDTH)) + .mt_1() + .px_2() + .py_1() + .flex() + .flex_row() + .items_center() + .justify_between() + .gap_2() + .bg(rgb(QUERY_BG)) + .border_1() + .border_color(rgb(if picker_open { THREAD_COLOR } else { 0x3a3a3a })) + .rounded_sm() + .cursor_pointer() + .hover(|d| d.border_color(rgb(if picker_open { THREAD_COLOR } else { 0x5a5a5a }))) + .child(control_content) + .child(assets::icon("icons/chevron-down.svg").text_color(rgb(MUTED_COLOR))) + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { + if this.quick_open.is_some() { + this.quick_open = None; + cx.notify(); + } else { + this.open_quick_open(QuickOpenPurpose::PickHome, window, cx); + } + })); + if picker_open { + // The open combobox is the keyboard target: typing, arrows, + // enter, and escape all land here (`open_quick_open` focused + // this handle when the picker opened). + control = control + .track_focus(&self.quick_open_focus) + .on_key_down(cx.listener(Self::quick_open_key_down)); + } div() .flex() .flex_col() @@ -1515,46 +1594,11 @@ impl TrawlerApp { .text_size(px(12.0)) .child( "What the app opens to, and where Home (ctrl-h) goes. \ - Pick a page or tag; \"Journal (default)\" restores the \ - journal timeline.", + Pick a page or tag; \"Journal (default)\" restores the \ + journal timeline.", ), ) - .child( - // A select, not a button: the label is the current - // *value* (body color, not accent), the chevron - // signals a choice unfolds, and the accent appears - // only as the open state's border. The dropdown - // below shares the control's width. - div() - .id("home-picker") - .w(px(HOME_SELECT_WIDTH)) - .mt_1() - .px_2() - .py_1() - .flex() - .flex_row() - .items_center() - .justify_between() - .gap_2() - .bg(rgb(QUERY_BG)) - .border_1() - .border_color(rgb(if picker_open { THREAD_COLOR } else { 0x3a3a3a })) - .rounded_sm() - .cursor_pointer() - .hover(|d| { - d.border_color(rgb(if picker_open { THREAD_COLOR } else { 0x5a5a5a })) - }) - .child(home_label) - .child(assets::icon("icons/chevron-down.svg").text_color(rgb(MUTED_COLOR))) - .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { - if this.quick_open.is_some() { - this.quick_open = None; - cx.notify(); - } else { - this.open_quick_open(QuickOpenPurpose::PickHome, window, cx); - } - })), - ) + .child(control) .children(dropdown.map(|d| div().mt_1().child(d))) } @@ -1950,7 +1994,7 @@ impl TrawlerApp { .flex_row() .items_start() .gap_2() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { this.navigate_to_search_hit(hit.clone(), window, cx); })) @@ -2838,6 +2882,10 @@ const MUTED_COLOR: u32 = 0x9a9a9a; /// selection (the thread accent over a dark ground), so "selected" reads /// as one color everywhere. const SELECTED_ROW_BG: u32 = 0x3a4a3d; +/// Hover wash for chrome buttons and clickable rows: a neutral raise of +/// the app ground, not a hue shift — hover is "you're pointing at me", +/// while color is reserved for selection and the accent. +const HOVER_BG: u32 = 0x2e2e2e; /// Default text color, set on the root element and also baked into each /// paragraph `TextRun` (runs carry explicit colors — they don't inherit). const BODY_TEXT_COLOR: u32 = 0xffffff; @@ -3154,7 +3202,7 @@ fn render_query_outcome( .flex_row() .items_start() .gap_1() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .child( div() .w(px(14.0)) @@ -3283,7 +3331,7 @@ fn render_calendar( let mut cell = base .id(("calendar-day", day as usize)) .cursor_pointer() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .child(day.to_string()) .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { this.navigate_to_calendar_day(date, window, cx); @@ -3339,7 +3387,7 @@ fn render_calendar( .px_1() .py_0p5() .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .child(assets::icon("icons/chevron-left.svg")) .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { this.shift_calendar_month(-1, cx); @@ -3357,7 +3405,7 @@ fn render_calendar( .px_1() .py_0p5() .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .child(assets::icon("icons/chevron-right.svg")) .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { this.shift_calendar_month(1, cx); @@ -3741,6 +3789,20 @@ impl Render for TrawlerApp { } self.check_journal_rollover(cx); let row_count = self.rows.len(); + // Whether a home document is designated (and, for a tree node, + // still alive): gates the titlebar's Home button — with the + // journal as home it would just duplicate the Journal button. + // Read-only: a dangling designation renders as "no home" here and + // is actually cleared by `resolve_home_view_for` on the next + // resolve, never during render. + let home_designated = trawler_core::settings::Settings::new(self.storage.doc()) + .home() + .is_some_and(|id| match &id { + NodeId::Tree(_) => id + .as_tree_id() + .is_some_and(|t| Outline::new(self.storage.doc()).exists(t)), + _ => true, + }); let focused_block = self.editor.as_ref().map(|e| e.block); let editor_input = self.editor.as_ref().map(|e| e.input.clone()); // Whether this view's depth-0 rows are page roots (openspec change @@ -4107,7 +4169,13 @@ impl Render for TrawlerApp { .as_ref() .filter(|state| state.purpose == QuickOpenPurpose::Navigate) .map(|state| { - let body = Self::render_quick_open_body(state, 420.0, &self.quick_open_focus, cx); + let body = Self::render_quick_open_body( + state, + 420.0, + Some(&self.quick_open_focus), + true, + cx, + ); deferred( gpui::anchored() .position(point(px(220.0), px(70.0))) @@ -4186,7 +4254,7 @@ impl Render for TrawlerApp { .h_full() .flex_none() .cursor_col_resize() - .hover(|d| d.bg(rgb(0x3a3a5a))) + .hover(|d| d.bg(rgb(0x454545))) .on_mouse_down( MouseButton::Left, cx.listener(|this, _event, _window, cx| { @@ -4244,7 +4312,7 @@ impl Render for TrawlerApp { .items_center() .gap_1() .cursor_pointer() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .child(assets::icon("icons/settings.svg")) .child("Settings") .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { @@ -4328,21 +4396,25 @@ impl Render for TrawlerApp { // natively on Windows. div() .h(px(34.0)) - .pl_2() .border_b_1() .border_color(rgb(0x3a3a3a)) .flex() .flex_row() .items_center() - .gap_2() + // No row gap and no rounding on the buttons: each one + // is a full-height slab carrying its own padding, so + // hover fills the bar's height exactly like the native + // Windows caption buttons on the right (which also sit + // flush against each other now). .child( div() .id("nav-back") .cursor_pointer() - .px_1() - .py_0p5() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .h_full() + .px_2() + .flex() + .items_center() + .hover(|d| d.bg(rgb(HOVER_BG))) .child(assets::icon("icons/arrow-left.svg")) .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { this.navigate_back(&NavigateBack, window, cx); @@ -4352,30 +4424,33 @@ impl Render for TrawlerApp { div() .id("nav-forward") .cursor_pointer() - .px_1() - .py_0p5() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .h_full() + .px_2() + .flex() + .items_center() + .hover(|d| d.bg(rgb(HOVER_BG))) .child(assets::icon("icons/arrow-right.svg")) .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { this.navigate_forward(&NavigateForward, window, cx); })), ) - .child( + .children(home_designated.then(|| { // Home + Journal (openspec change home-document, // spec: app-chrome/"Home and Journal header - // controls") in place of the old Calendar button: - // Home resolves the designated home document - // (journal when unset — never inert); Journal is - // the timeline's permanent way back once a home - // is designated. The calendar stays reachable via - // the sidebar and ctrl-shift-c. + // controls") in place of the old Calendar button. + // Home only appears once a home document is + // designated — with the journal as home the two + // buttons would be the same destination, so the + // Journal button carries it alone. (The ctrl-h + // action still works either way.) The calendar + // stays reachable via the sidebar and + // ctrl-shift-c. div() .id("go-home") .cursor_pointer() + .h_full() .px_2() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .flex() .flex_row() .items_center() @@ -4384,15 +4459,15 @@ impl Render for TrawlerApp { .child("Home") .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { this.go_home(&GoHome, window, cx); - })), - ) + })) + })) .child( div() .id("go-journal") .cursor_pointer() + .h_full() .px_2() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .hover(|d| d.bg(rgb(HOVER_BG))) .flex() .flex_row() .items_center() @@ -4409,6 +4484,7 @@ impl Render for TrawlerApp { .window_control_area(WindowControlArea::Drag) .flex_1() .h_full() + .px_2() .flex() .items_center() .overflow_hidden() @@ -4425,10 +4501,11 @@ impl Render for TrawlerApp { div() .id("sidebar-toggle") .cursor_pointer() - .px_1() - .py_0p5() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) + .h_full() + .px_2() + .flex() + .items_center() + .hover(|d| d.bg(rgb(HOVER_BG))) .child(assets::icon(if self.sidebar_open { "icons/layout-sidebar-right-collapse.svg" } else { @@ -4440,7 +4517,7 @@ impl Render for TrawlerApp { ) .child( caption_button("caption-min", "icons/minus.svg", WindowControlArea::Min) - .hover(|d| d.bg(rgb(0x2a2a3a))), + .hover(|d| d.bg(rgb(HOVER_BG))), ) .child( caption_button( @@ -4452,7 +4529,7 @@ impl Render for TrawlerApp { }, WindowControlArea::Max, ) - .hover(|d| d.bg(rgb(0x2a2a3a))), + .hover(|d| d.bg(rgb(HOVER_BG))), ) .child( caption_button("caption-close", "icons/x.svg", WindowControlArea::Close)