Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,12 @@ Invoke-Native "Zmx session identity executable tests" {
& $zig test src\ZmxSession.zig
} finally { Pop-Location }
}
Invoke-Native "Loop bar layout executable tests" {
Push-Location $shellRoot
try {
& $zig test src\LoopBarLayout.zig
} finally { Pop-Location }
}
Invoke-Native "Terminal VT preparation and memory tests" {
$depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent
$winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT")
Expand Down
18 changes: 14 additions & 4 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const GraphModel = @import("GraphModel.zig");
const InputRouter = @import("InputRouter.zig");
const MainWindow = @import("MainWindow.zig");
const TerminalWorkspace = @import("TerminalWorkspace.zig");
const LoopBarLayout = @import("LoopBarLayout.zig");
const Clipboard = @import("Clipboard.zig");
const Tokens = @import("DesignTokens.zig");
const Dpi = @import("Dpi.zig");
Expand Down Expand Up @@ -957,6 +958,10 @@ fn isResolvedLoopState(state: []const u8) bool {
std.mem.eql(u8, state, "stopped");
}

fn loopBarRect(rect: LoopBarLayout.Rect) c.RECT {
return .{ .left = rect.left, .top = rect.top, .right = rect.right, .bottom = rect.bottom };
}

fn workspaceGraph(model: *const GraphModel.Model) ?*const GraphModel.GraphSummary {
if (model.currentGraph()) |graph| if (graph.nodes.items.len != 0) return graph;
if (model.selected_project_path) |path| {
Expand Down Expand Up @@ -6113,14 +6118,19 @@ pub const App = struct {
const workspace_left = if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0;
const workspace_right = client.right - (if (self.workspace_controls.panel_visible) Tokens.loop_detail_width else 0);
const selected_index = self.model.selectedIndex() orelse 0;
const loop_bar = TerminalWorkspace.loopBarLayout(
workspace_left,
workspace_right,
selected_index >= graph.nodes.items.len or isResolvedLoopState(graph.nodes.items[selected_index].state),
);
if (!self.workspace_is_quick_chat) {
self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, header.title, 4, .{ .logical = header_layout.identity }, false, false) catch return;
elements.items[elements.items.len - 1].invokable = false;
}
self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .logical = .{ .left = workspace_left, .top = Tokens.header_height, .right = workspace_right, .bottom = Tokens.header_height + Tokens.loop_bar_height } }, false, false) catch return;
self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = .{ .left = workspace_right - 104, .top = Tokens.header_height + 10, .right = workspace_right - 12, .bottom = Tokens.header_height + 36 } }, false, false) catch return;
if (selected_index < graph.nodes.items.len and !isResolvedLoopState(graph.nodes.items[selected_index].state)) {
self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .logical = .{ .left = workspace_right - 196, .top = Tokens.header_height + 10, .right = workspace_right - 112, .bottom = Tokens.header_height + 36 } }, false, false) catch return;
self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .logical = loopBarRect(loop_bar.bar) }, false, false) catch return;
self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = loopBarRect(loop_bar.show_graph) }, false, false) catch return;
if (loop_bar.stop) |stop| {
self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .logical = loopBarRect(stop) }, false, false) catch return;
}
const panel_toggle = if (self.workspace_controls.panel_visible)
GraphCanvas.loopDetailCollapseBounds(client.right)
Expand Down
156 changes: 156 additions & 0 deletions graphcode-windows/src/LoopBarLayout.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
const std = @import("std");
const Tokens = @import("DesignTokens.zig");

// Logical-pixel geometry of the 46px workspace loop bar. Painting, mouse hit-testing,
// and UIA bounds all read these rects so the drawn buttons and their actionable areas
// cannot drift apart.

pub const Rect = struct {
left: i32,
top: i32,
right: i32,
bottom: i32,

pub fn width(self: Rect) i32 {
return self.right - self.left;
}

pub fn contains(self: Rect, x: i32, y: i32) bool {
return x >= self.left and x < self.right and y >= self.top and y < self.bottom;
}

pub fn intersects(self: Rect, other: Rect) bool {
return self.left < other.right and other.left < self.right and self.top < other.bottom and other.top < self.bottom;
}
};

pub const Action = enum { stop, show_graph };

// Every text run in the bar is drawn into a fixed 220px-wide rect when it has room.
pub const natural_text_width: i32 = 220;
// Clearance kept between the last text pixel and the leftmost button.
pub const button_gap: i32 = 8;
// Narrower than this a run would show little more than an ellipsis, so it is dropped.
pub const min_text_width: i32 = 24;

pub const Layout = struct {
bar: Rect,
stripe: ?Rect,
title: ?Rect,
state: ?Rect,
activity: ?Rect,
detail: ?Rect,
stop: ?Rect,
show_graph: Rect,

// Buttons accept clicks across the bar's full height, using their drawn columns.
pub fn actionAt(self: Layout, x: i32, y: i32) ?Action {
if (!self.bar.contains(x, y)) return null;
if (x >= self.show_graph.left and x < self.show_graph.right) return .show_graph;
if (self.stop) |stop| if (x >= stop.left and x < stop.right) return .stop;
return null;
}
};

fn textRect(x: i32, y: i32, size: i32, limit: i32) ?Rect {
const right = @min(x + natural_text_width, limit);
if (right - x < min_text_width) return null;
return .{ .left = x, .top = y, .right = right, .bottom = y + size + 8 };
}

pub fn compute(left: i32, top: i32, right: i32, resolved: bool) Layout {
const show_graph = Rect{ .left = right - 104, .top = top + 10, .right = right - 12, .bottom = top + 36 };
const stop: ?Rect = if (resolved) null else .{ .left = right - 196, .top = top + 10, .right = right - 112, .bottom = top + 36 };
const limit = (if (stop) |value| value.left else show_graph.left) - button_gap;
const stripe = Rect{ .left = left + 14, .top = top + 11, .right = left + 18, .bottom = top + 35 };
return .{
.bar = .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height },
.stripe = if (stripe.right <= limit) stripe else null,
.title = textRect(left + 27, top + 5, 13, limit),
.state = textRect(left + 190, top + 8, 10, limit),
.activity = textRect(left + 27, top + 24, 10, limit),
.detail = textRect(left + 260, top + 10, 9, limit),
.stop = stop,
.show_graph = show_graph,
};
}

fn expectClearOfButtons(layout: Layout) !void {
const runs = [_]?Rect{ layout.stripe, layout.title, layout.state, layout.activity, layout.detail };
for (runs) |maybe_run| {
const run = maybe_run orelse continue;
try std.testing.expect(run.width() > 0);
try std.testing.expect(run.left >= layout.bar.left);
try std.testing.expect(!run.intersects(layout.show_graph));
if (layout.stop) |stop| try std.testing.expect(!run.intersects(stop));
}
}

test "loop bar metadata stays clear of Stop and Show in graph at 960px with rail and detail panel" {
// beta10 Dev Box qualification: 960px shell, 220px sidebar rail, 272px loop detail panel.
const left = Tokens.sidebar_width;
const right = 960 - Tokens.loop_detail_width;
const layout = compute(left, Tokens.header_height, right, false);
try expectClearOfButtons(layout);
// Only 4px remain between the metadata origin and Stop, so the run is dropped.
try std.testing.expect(layout.detail == null);
try std.testing.expect(layout.title != null and layout.state != null and layout.activity != null);
}

test "loop bar ellipsizes metadata into the space left of Stop at 1056px with rail and detail panel" {
const left = Tokens.sidebar_width;
const right = 1056 - Tokens.loop_detail_width;
const layout = compute(left, Tokens.header_height, right, false);
try expectClearOfButtons(layout);
const detail = layout.detail.?;
try std.testing.expectEqual(left + 260, detail.left);
try std.testing.expectEqual(layout.stop.?.left - button_gap, detail.right);
try std.testing.expect(detail.width() < natural_text_width);
}

test "loop bar keeps the wide 1280px geometry unchanged" {
const left = Tokens.sidebar_width;
const right = 1280 - Tokens.loop_detail_width;
const top = Tokens.header_height;
const layout = compute(left, top, right, false);
try expectClearOfButtons(layout);
try std.testing.expectEqual(Rect{ .left = left + 14, .top = top + 11, .right = left + 18, .bottom = top + 35 }, layout.stripe.?);
try std.testing.expectEqual(Rect{ .left = left + 27, .top = top + 5, .right = left + 247, .bottom = top + 26 }, layout.title.?);
try std.testing.expectEqual(Rect{ .left = left + 190, .top = top + 8, .right = left + 410, .bottom = top + 26 }, layout.state.?);
try std.testing.expectEqual(Rect{ .left = left + 27, .top = top + 24, .right = left + 247, .bottom = top + 42 }, layout.activity.?);
try std.testing.expectEqual(Rect{ .left = left + 260, .top = top + 10, .right = left + 480, .bottom = top + 27 }, layout.detail.?);
try std.testing.expectEqual(Rect{ .left = right - 196, .top = top + 10, .right = right - 112, .bottom = top + 36 }, layout.stop.?);
try std.testing.expectEqual(Rect{ .left = right - 104, .top = top + 10, .right = right - 12, .bottom = top + 36 }, layout.show_graph);
}

test "loop bar drops text that cannot fit beside the buttons on an extremely narrow bar" {
const left = Tokens.sidebar_width;
const right = left + 240;
const layout = compute(left, Tokens.header_height, right, false);
try expectClearOfButtons(layout);
try std.testing.expect(layout.title == null and layout.state == null and layout.activity == null and layout.detail == null);
try std.testing.expectEqual(right - 12, layout.show_graph.right);
const sliver = compute(left, Tokens.header_height, left + 100, false);
try expectClearOfButtons(sliver);
try std.testing.expect(sliver.stripe == null);
}

test "loop bar text reclaims the Stop slot once the loop is resolved" {
const left = Tokens.sidebar_width;
const right = 960 - Tokens.loop_detail_width;
const resolved = compute(left, Tokens.header_height, right, true);
try std.testing.expect(resolved.stop == null);
try expectClearOfButtons(resolved);
try std.testing.expectEqual(resolved.show_graph.left - button_gap, resolved.detail.?.right);
}

test "loop bar actions hit exactly the button rects" {
const layout = compute(220, 34, 1200, false);
try std.testing.expectEqual(Action.stop, layout.actionAt(1010, 50).?);
try std.testing.expectEqual(Action.show_graph, layout.actionAt(1120, 50).?);
try std.testing.expectEqual(Action.show_graph, layout.actionAt(1120, 35).?);
try std.testing.expect(layout.actionAt(1120, 80) == null);
try std.testing.expect(layout.actionAt(1120, 90) == null);
try std.testing.expect(layout.actionAt(layout.stop.?.right, 50) == null);
try std.testing.expect(compute(220, 34, 1200, true).actionAt(1010, 50) == null);
}
55 changes: 35 additions & 20 deletions graphcode-windows/src/TerminalSurface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const std = @import("std");
const c = @import("Win32.zig").c;
const WorkspaceLayout = @import("WorkspaceLayout.zig");
const Tokens = @import("DesignTokens.zig");
const LoopBarLayout = @import("LoopBarLayout.zig");
const AppFont = @import("AppFont.zig");
const GdiGradient = @import("GdiGradient.zig");
const Dpi = @import("Dpi.zig");
Expand Down Expand Up @@ -159,14 +160,14 @@ const max_surfaces: usize = 32;

pub const ChromeAction = enum { new_tab, split_right, split_down };
pub const TabAction = enum { select, close };
pub const LoopBarAction = enum { stop, show_graph };
pub const LoopBarAction = LoopBarLayout.Action;

pub fn loopBarLayout(left: i32, right: i32, resolved: bool) LoopBarLayout.Layout {
return LoopBarLayout.compute(left, Tokens.header_height, right, resolved);
}

pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved: bool) ?LoopBarAction {
if (y < top or y >= top + Tokens.loop_bar_height) return null;
if (x >= right - 104 and x < right - 12) return .show_graph;
if (!resolved and x >= right - 196 and x < right - 112) return .stop;
_ = left;
return null;
return LoopBarLayout.compute(left, top, right, resolved).actionAt(x, y);
}

fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i32) ?ChromeAction {
Expand Down Expand Up @@ -1008,18 +1009,16 @@ pub const Workspace = struct {
resolved: bool,
) void {
const top = Tokens.header_height;
const layout = loopBarLayout(left, right, resolved);
// Theme.loopBar: lit like the tab strip, one step lighter.
GdiGradient.fillVertical(hdc, .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height }, Tokens.loop_bar_top, Tokens.loop_bar_bottom);
fillRect(hdc, .{
.left = left + 14,
.top = top + 11,
.right = left + 18,
.bottom = top + 35,
}, loopTypeAccent(loop_type));
drawUtf8(hdc, title, left + 27, top + 5, 13, 0x00F2F2F7);
drawUtf8(hdc, state, left + 190, top + 8, 10, stateAccent(state));
if (layout.stripe) |stripe| {
fillRect(hdc, .{ .left = stripe.left, .top = stripe.top, .right = stripe.right, .bottom = stripe.bottom }, loopTypeAccent(loop_type));
}
if (layout.title) |bounds| drawUtf8Bounded(hdc, title, bounds, 13, 0x00F2F2F7);
if (layout.state) |bounds| drawUtf8Bounded(hdc, state, bounds, 10, stateAccent(state));
const live_line = if (activity.len != 0) activity else project_name;
drawUtf8(hdc, live_line, left + 27, top + 24, 10, 0x008E8E93);
if (layout.activity) |bounds| drawUtf8Bounded(hdc, live_line, bounds, 10, 0x008E8E93);
var usage: [32]u8 = undefined;
const usage_text = if (token_usage) |value| std.fmt.bufPrint(&usage, "{d} tokens", .{value}) catch "usage n/a" else "usage n/a";
var detail: [256]u8 = undefined;
Expand All @@ -1029,12 +1028,12 @@ pub const Workspace = struct {
metric_passes,
usage_text,
}) catch "workspace metadata unavailable";
drawUtf8(hdc, detail_text, left + 260, top + 10, 9, 0x008E8E93);
if (!resolved) {
fillRect(hdc, .{ .left = right - 196, .top = top + 10, .right = right - 112, .bottom = top + 36 }, 0x00303035);
drawUtf8(hdc, "Stop loop", right - 184, top + 17, 10, 0x00D8D8DC);
if (layout.detail) |bounds| drawUtf8Bounded(hdc, detail_text, bounds, 9, 0x008E8E93);
if (layout.stop) |stop| {
fillRect(hdc, .{ .left = stop.left, .top = stop.top, .right = stop.right, .bottom = stop.bottom }, 0x00303035);
drawUtf8(hdc, "Stop loop", stop.left + 12, top + 17, 10, 0x00D8D8DC);
}
drawUtf8(hdc, "Show in graph", right - 100, top + 17, 10, 0x008E8E93);
drawUtf8(hdc, "Show in graph", layout.show_graph.left + 4, top + 17, 10, 0x008E8E93);
// Theme.tabBarShadowLine, blended flat over the loop bar's own bottom stop --
// the edge where the strip's gloss meets the terminal below it.
fillRect(hdc, .{ .left = left, .top = top + Tokens.loop_bar_height - 1, .right = right, .bottom = top + Tokens.loop_bar_height }, Tokens.tab_bar_shadow_line);
Expand Down Expand Up @@ -4045,6 +4044,22 @@ fn drawUtf8(hdc: c.HDC, text: []const u8, x: i32, y: i32, size: i32, color: u32)
_ = c.SelectObject(hdc, old_font);
}

// Clipped to `bounds`; a run squeezed narrower than its natural width ends in an
// ellipsis instead of being cut mid-glyph.
fn drawUtf8Bounded(hdc: c.HDC, text: []const u8, bounds: LoopBarLayout.Rect, size: i32, color: u32) void {
const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch return;
defer std.heap.page_allocator.free(wide);
if (wide.len == 0) return;
const old_font = AppFont.select(hdc, size, false);
_ = c.SetTextColor(hdc, color);
_ = c.SetBkMode(hdc, c.TRANSPARENT);
var rect = c.RECT{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom };
const ellipsis: c_int = if (bounds.width() < LoopBarLayout.natural_text_width) c.DT_END_ELLIPSIS else 0;
const format: c.UINT = @intCast(c.DT_LEFT | c.DT_SINGLELINE | ellipsis);
_ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &rect, format);
_ = c.SelectObject(hdc, old_font);
}

fn tabLabel(tab: WorkspaceLayout.Tab, index: usize) []const u8 {
if (tab.panes.items.len > 1) return "split";
if (index == 0) return "agent";
Expand Down
1 change: 1 addition & 0 deletions graphcode-windows/src/TerminalWorkspace.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ const TerminalSurface = @import("TerminalSurface.zig");
pub const Workspace = TerminalSurface.Workspace;
pub const LoopBarAction = TerminalSurface.LoopBarAction;
pub const loopBarActionAt = TerminalSurface.loopBarActionAt;
pub const loopBarLayout = TerminalSurface.loopBarLayout;
pub const tabBounds = TerminalSurface.tabBounds;
pub const chromeControlBounds = TerminalSurface.chromeControlBounds;
Loading
Loading