diff --git a/crates/trawler/assets/icons/minus.svg b/crates/trawler/assets/icons/minus.svg
new file mode 100644
index 0000000..5d6501f
--- /dev/null
+++ b/crates/trawler/assets/icons/minus.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/crates/trawler/assets/icons/square.svg b/crates/trawler/assets/icons/square.svg
new file mode 100644
index 0000000..a7efd5a
--- /dev/null
+++ b/crates/trawler/assets/icons/square.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/crates/trawler/assets/icons/squares.svg b/crates/trawler/assets/icons/squares.svg
new file mode 100644
index 0000000..539c987
--- /dev/null
+++ b/crates/trawler/assets/icons/squares.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/crates/trawler/assets/icons/x.svg b/crates/trawler/assets/icons/x.svg
new file mode 100644
index 0000000..40a873c
--- /dev/null
+++ b/crates/trawler/assets/icons/x.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/crates/trawler/src/assets.rs b/crates/trawler/src/assets.rs
index 3c0e1da..0fcfec4 100644
--- a/crates/trawler/src/assets.rs
+++ b/crates/trawler/src/assets.rs
@@ -43,6 +43,20 @@ const ICONS: &[(&str, &[u8])] = &[
"icons/layout-sidebar-right-expand.svg",
include_bytes!("../assets/icons/layout-sidebar-right-expand.svg"),
),
+ // Window caption glyphs (design D1): minimize, maximize, restore, close.
+ (
+ "icons/minus.svg",
+ include_bytes!("../assets/icons/minus.svg"),
+ ),
+ (
+ "icons/square.svg",
+ include_bytes!("../assets/icons/square.svg"),
+ ),
+ (
+ "icons/squares.svg",
+ include_bytes!("../assets/icons/squares.svg"),
+ ),
+ ("icons/x.svg", include_bytes!("../assets/icons/x.svg")),
];
/// The application's [`AssetSource`], registered via
diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs
index 464afce..15a1b57 100644
--- a/crates/trawler/src/main.rs
+++ b/crates/trawler/src/main.rs
@@ -51,7 +51,7 @@ use gpui::{
Application, Bounds, ClickEvent, Context, Entity, FocusHandle, Focusable as _, FontWeight,
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, ListAlignment, ListState,
MouseButton, ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription,
- Window, WindowBounds, WindowOptions,
+ TitlebarOptions, Window, WindowBounds, WindowControlArea, WindowOptions,
};
use loro::TreeID;
use markdown::{Block, Inline};
@@ -2405,6 +2405,28 @@ struct RowSnapshot {
query_outcome: Option,
}
+/// A window caption button (min/max/close) for the client-drawn titlebar.
+/// The `WindowControlArea` maps its bounds to the platform's native
+/// non-client hit-test (HTMINBUTTON etc. on Windows), so hover flyouts
+/// (Snap Layouts) and clicks are OS-handled there; the `on_click` attached
+/// at the call site covers paths that deliver plain clicks instead (e.g.
+/// the test platform).
+fn caption_button(
+ id: &'static str,
+ icon_path: &'static str,
+ area: WindowControlArea,
+) -> gpui::Stateful {
+ div()
+ .id(id)
+ .window_control_area(area)
+ .w(px(46.0))
+ .h_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(assets::icon(icon_path).size(px(14.0)))
+}
+
impl Render for TrawlerApp {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
self.check_journal_rollover(cx);
@@ -2745,8 +2767,17 @@ impl Render for TrawlerApp {
// `MONO_FONT` code/Scheme spans.
.font_family(UI_FONT)
.child(
+ // The titlebar row (openspec change ui-polish, design D1):
+ // with the native titlebar hidden (`appears_transparent`),
+ // this row is the window caption — nav controls on the
+ // left, a drag region through the middle, caption buttons
+ // flush to the right edge. The platform maps the control
+ // areas to native hit-test codes, so drag, double-click
+ // maximize, Snap Layouts, and caption clicks behave
+ // natively on Windows.
div()
- .p_2()
+ .h(px(34.0))
+ .pl_2()
.border_b_1()
.border_color(rgb(0x3a3a3a))
.flex()
@@ -2813,11 +2844,52 @@ impl Render for TrawlerApp {
)
.children(calendar_overlay),
)
- .child(div().flex_1().child(format!(
- "trawler — {title} — {} ({} blocks)",
- self.graph_dir.display(),
- row_count
- ))),
+ .child(
+ div()
+ .id("titlebar-drag")
+ .window_control_area(WindowControlArea::Drag)
+ .flex_1()
+ .h_full()
+ .flex()
+ .items_center()
+ .overflow_hidden()
+ .child(format!(
+ "trawler — {title} — {} ({} blocks)",
+ self.graph_dir.display(),
+ row_count
+ )),
+ )
+ .child(
+ caption_button("caption-min", "icons/minus.svg", WindowControlArea::Min)
+ .hover(|d| d.bg(rgb(0x2a2a3a)))
+ .on_click(cx.listener(|_, _: &ClickEvent, window, _| {
+ window.minimize_window();
+ })),
+ )
+ .child(
+ caption_button(
+ "caption-max",
+ if window.is_maximized() {
+ "icons/squares.svg"
+ } else {
+ "icons/square.svg"
+ },
+ WindowControlArea::Max,
+ )
+ .hover(|d| d.bg(rgb(0x2a2a3a)))
+ .on_click(cx.listener(
+ |_, _: &ClickEvent, window, _| {
+ window.zoom_window();
+ },
+ )),
+ )
+ .child(
+ caption_button("caption-close", "icons/x.svg", WindowControlArea::Close)
+ .hover(|d| d.bg(rgb(0xc42b1c)).text_color(rgb(0xffffff)))
+ .on_click(cx.listener(|_, _: &ClickEvent, window, _| {
+ window.remove_window();
+ })),
+ ),
)
.children(view_header)
.children(breadcrumb_bar)
@@ -3095,6 +3167,15 @@ fn main() {
.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
+ // Client-drawn chrome (openspec change ui-polish,
+ // design D1): hide the native titlebar; the app's
+ // header row doubles as the caption via
+ // WindowControlArea hit-test regions.
+ titlebar: Some(TitlebarOptions {
+ title: Some("trawler".into()),
+ appears_transparent: true,
+ traffic_light_position: None,
+ }),
..Default::default()
},
move |window, cx| cx.new(|cx| TrawlerApp::new(open_dir.clone(), window, cx)),
diff --git a/openspec/changes/ui-polish/tasks.md b/openspec/changes/ui-polish/tasks.md
index c2d1713..e379302 100644
--- a/openspec/changes/ui-polish/tasks.md
+++ b/openspec/changes/ui-polish/tasks.md
@@ -5,8 +5,8 @@
## 2. Window chrome (design D1)
-- [ ] 2.1 Switch `WindowOptions` to a transparent/client titlebar (verify exact gpui 0.2 API; Zed's Windows titlebar is the reference); render the titlebar row: drag region, caption controls, existing header controls folded in
-- [ ] 2.2 Verify drag-to-move, double-click maximize/restore, minimize/close on Windows via dev-loop; if gpui's caption handling is incomplete, fall back to stock titlebar and record the finding in this file
+- [x] 2.1 Switch `WindowOptions` to a transparent/client titlebar (`TitlebarOptions::appears_transparent`); the header row became the titlebar — nav/calendar controls left, title text as the drag region (`WindowControlArea::Drag`), caption buttons (min / max-restore with icon swap via `is_maximized` / close with Windows-red hover) flush right, 34px row
+- [x] 2.2 Verified on Windows: gpui 0.2.2's `WindowControlArea` hitboxes map to native NCHITTEST codes (HTCAPTION/HTMINBUTTON/HTMAXBUTTON/HTCLOSE), so drag, double-click maximize/restore, Snap Layouts flyout, and caption clicks are OS-handled — **no fallback needed**. Caption buttons also carry plain `on_click` handlers for client-click paths (test platform). User-verified interactively (drag, snap, caption behavior) plus dev-loop screenshot
## 3. Sidebar (design D3)