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
39 changes: 39 additions & 0 deletions docs/specs/sidebar-panel-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Sidebar / bottom panel layout

## Confirmed behavior

- USER / CONFIRMED: On macOS, the explorer tree and outline occupy the full sidebar from
their existing top positions through the status-bar top. Their geometry is
independent of the bottom console/panel height and hidden state.
- IMPLEMENTATION / CONFIRMED (existing): The Git sidebar also occupies the full sidebar height through the
status-bar top.
- IMPLEMENTATION / CONFIRMED: The sidebar divider is full height and owns the left side of the
sidebar/panel splitter corner. The bottom-panel splitter is draggable only
on the editor/right side.
- IMPLEMENTATION / CONFIRMED (existing): Existing outline auto sizing and collapse behavior is preserved; no
outline drag affordance is introduced.
- IMPLEMENTATION / ASSUMED (coordinator parity scope): Windows matches the corrected splitter ownership; its existing sidebar geometry already uses the full height.
- IMPLEMENTATION / CONFIRMED: Native macOS regression coverage demonstrates panel resizing and
hiding invariance, lower-row/outline scrolling capacity and hit testing,
Git height, and splitter x/y boundaries.

## Implementation units

| ID | Responsibility | Status | Dependencies |
| --- | --- | --- | --- |
| macos-geometry | Decouple sidebar/tree/outline/Git geometry from bottom panel height. | COMPLETE | existing layout metrics |
| splitter-ownership | Make the sidebar divider full-height and reserve panel splitter to the right region. | COMPLETE | macos-geometry; Windows parity |
| windows-parity | Apply the same splitter ownership contract to Windows while preserving its existing full-height sidebar geometry. | COMPLETE | shared layout metrics |
| macos-regression | Extend the native macOS workspace layout regression for the corrected contract. | COMPLETE | macos-geometry; splitter-ownership |

## Validation state

- `build` is configured with `BUILD_TESTING=ON`. Coordinator build succeeded;
native `axyne-workspace-layout` passed in 0.53 seconds after correcting the
remaining console-height subtraction in `explorerTreeBottom` and the
windowless test fixture's wheel-event coordinates. Existing test timeout
is unchanged.
- Separate ephemeral review completed; findings were resolved. Windows was
inspected for parity but cannot be built or executed on this macOS host.
- Full CTest suite passed: 37/37 tests, 26.99 seconds.
- Work stays on `fix/sidebar-panel-layout`, without commits, pushes or PRs.
32 changes: 19 additions & 13 deletions src/features/ui/platform/macos/ui_macos.m
Original file line number Diff line number Diff line change
Expand Up @@ -3689,7 +3689,7 @@ - (BOOL)outlineVisible
* when it is hidden. The cap is a share of the whole explorer column. */
- (CGFloat)outlineHeight
{
CGFloat explorer = NSHeight([self bounds]) - AXYNE_STATUS - [self panelHeight] -
CGFloat explorer = NSHeight([self bounds]) - AXYNE_STATUS -
(AXYNE_CONTENT_TOP + AXYNE_TABS);
if (![self outlineVisible] || explorer <= 0) return 0;
return (CGFloat)axyne_outline_height(&_outline, (int)explorer);
Expand All @@ -3698,13 +3698,13 @@ - (CGFloat)outlineHeight
/* The file tree ends where the outline begins. */
- (CGFloat)explorerTreeBottom
{
return NSHeight([self bounds]) - AXYNE_STATUS - [self panelHeight] - [self outlineHeight];
return NSHeight([self bounds]) - AXYNE_STATUS - [self outlineHeight];
}

- (BOOL)pointIsInOutline:(NSPoint)point
{
CGFloat height = [self outlineHeight];
CGFloat bottom = NSHeight([self bounds]) - AXYNE_STATUS - [self panelHeight];
CGFloat bottom = NSHeight([self bounds]) - AXYNE_STATUS;
return height > 0 && point.x >= 0 && point.x < [self sidebarWidth] &&
point.y >= bottom - height && point.y < bottom;
}
Expand All @@ -3713,7 +3713,7 @@ - (BOOL)pointIsInOutline:(NSPoint)point
- (NSInteger)outlineSymbolAtPoint:(NSPoint)point
{
CGFloat height = [self outlineHeight];
CGFloat top = NSHeight([self bounds]) - AXYNE_STATUS - [self panelHeight] - height;
CGFloat top = NSHeight([self bounds]) - AXYNE_STATUS - height;
if (![self pointIsInOutline:point]) return -1;
return (NSInteger)axyne_outline_symbol_at(&_outline, (int)height, (int)(point.y - top));
}
Expand Down Expand Up @@ -4164,16 +4164,18 @@ - (CGFloat)panelHeight
(int)NSHeight([self bounds]), AXYNE_UI_MENU + AXYNE_UI_TOOLBAR + AXYNE_UI_TABS);
}

/* Splitter under `point` (view coordinates): 1 = explorer/editor border,
* 2 = bottom panel top border, 0 = none. The panel wins at the corner. */
/* Splitter under `point` (view coordinates): 1 = sidebar/editor border,
* 2 = bottom panel top border, 0 = none. The sidebar owns the left corner;
* the panel splitter exists only in the editor/right region. */
- (NSInteger)splitterAtPoint:(NSPoint)point
{
NSRect bounds = [self bounds];
CGFloat panel = [self panelHeight];
CGFloat bottomTop = NSHeight(bounds) - AXYNE_STATUS - panel;
CGFloat contentTop = AXYNE_CONTENT_TOP + AXYNE_TABS;
if (point.x < 0 || point.x >= NSWidth(bounds)) return 0;
if (axyne_layout_on_panel_splitter((int)floor(point.y), (int)bottomTop, (int)panel))
if (point.x >= [self sidebarWidth] &&
axyne_layout_on_panel_splitter((int)floor(point.y), (int)bottomTop, (int)panel))
return 2;
if (point.y >= contentTop && point.y < NSHeight(bounds) - AXYNE_STATUS &&
axyne_layout_on_sidebar_splitter((int)floor(point.x), (int)[self sidebarWidth]))
Expand All @@ -4187,8 +4189,10 @@ - (NSRect)splitterRect:(NSInteger)which
if (which == 2) {
CGFloat panel = [self panelHeight];
if (panel <= 0) return NSZeroRect;
return NSMakeRect(0, NSHeight(bounds) - AXYNE_STATUS - panel - AXYNE_LAYOUT_GRAB_BEFORE_PANEL,
NSWidth(bounds), AXYNE_LAYOUT_GRAB_BEFORE_PANEL + AXYNE_LAYOUT_GRAB_AFTER_PANEL);
CGFloat left = [self sidebarWidth];
return NSMakeRect(left, NSHeight(bounds) - AXYNE_STATUS - panel - AXYNE_LAYOUT_GRAB_BEFORE_PANEL,
MAX(0, NSWidth(bounds) - left),
AXYNE_LAYOUT_GRAB_BEFORE_PANEL + AXYNE_LAYOUT_GRAB_AFTER_PANEL);
}
CGFloat sidebar = [self sidebarWidth];
if (sidebar <= 0) return NSZeroRect;
Expand Down Expand Up @@ -5295,7 +5299,7 @@ - (void)mouseDown:(NSEvent *)event
if (_sidebarTab != 0) return;
}
if (_sidebarTab == 0 && point.x < [self sidebarWidth] && point.y >= AXYNE_CONTENT_TOP + AXYNE_TABS &&
point.y < NSHeight([self bounds]) - AXYNE_STATUS - [self panelHeight]) {
point.y < NSHeight([self bounds]) - AXYNE_STATUS) {
if ([self pointIsInOutline:point]) {
/* Never a tree click: flush a pending rescan so the row maps to
* the current text, then jump. */
Expand Down Expand Up @@ -5355,7 +5359,7 @@ - (void)rightMouseDown:(NSEvent *)event
point.y >= AXYNE_CONTENT_TOP + AXYNE_TABS) return;
if ([self pointIsInOutline:point]) return; /* no tree menu over the outline */
if (point.x >= [self sidebarWidth] || point.y < AXYNE_CONTENT_TOP + AXYNE_TABS ||
point.y >= NSHeight([self bounds]) - AXYNE_STATUS - [self panelHeight]) {
point.y >= NSHeight([self bounds]) - AXYNE_STATUS) {
[super rightMouseDown:event];
return;
}
Expand Down Expand Up @@ -6791,6 +6795,7 @@ - (void)layout
[super layout];
NSRect bounds = [self bounds];
CGFloat width = NSWidth(bounds);
CGFloat statusTop = NSHeight(bounds) - AXYNE_STATUS;
CGFloat bottomTop = NSHeight(bounds) - AXYNE_STATUS - [self panelHeight];
CGFloat editorTop = AXYNE_CONTENT_TOP + AXYNE_TABS;
[_editorView setFrame:NSMakeRect([self sidebarWidth], editorTop,
Expand All @@ -6812,7 +6817,7 @@ - (void)layout
}
CGFloat outlineHeight = [self outlineHeight];
(void)axyne_outline_set_scroll(&_outline, (long)_outline.first_row, (int)outlineHeight);
NSInteger visibleRows = MAX(1, (NSInteger)((bottomTop - outlineHeight - editorTop -
NSInteger visibleRows = MAX(1, (NSInteger)((statusTop - outlineHeight - editorTop -
AXYNE_UI_EXPLORER_HEADER) / AXYNE_UI_ROW));
_explorerFirstRow = MIN(_explorerFirstRow, MAX(0, (NSInteger)_explorer.count - visibleRows));
BOOL terminal = _panelMode == 2;
Expand Down Expand Up @@ -7071,7 +7076,8 @@ - (void)drawRect:(NSRect)dirtyRect
if (!_explorerHidden)
NSRectFill(NSMakeRect([self sidebarWidth] - 1, editorTop, 1, statusTop - editorTop));
if (!_panelHidden)
NSRectFill(NSMakeRect(0, bottomTop, width, 1));
NSRectFill(NSMakeRect([self sidebarWidth], bottomTop,
MAX(0, width - [self sidebarWidth]), 1));
if (![_searchButton isHidden]) {
[axyne_preference_color(reference ? 0x3a3d44 : _preferences.theme.border) setStroke];
[[NSBezierPath bezierPathWithRoundedRect:[_searchButton frame] xRadius:4 yRadius:4] stroke];
Expand Down
10 changes: 6 additions & 4 deletions src/features/ui/platform/windows/ui_windows.c
Original file line number Diff line number Diff line change
Expand Up @@ -343,15 +343,17 @@ static int axyne_panel_height(const AxyneWindowState *state)
AXYNE_UI_MENU + AXYNE_UI_TOOLBAR + AXYNE_UI_TABS);
}

/* The two user-draggable borders: 1 = explorer/editor, 2 = bottom panel top,
* 0 = none. The panel wins at the corner. The explorer, panel and menu sizes
* are the clamped, cached values, so hit-testing matches what is painted. */
/* The two user-draggable borders: 1 = sidebar/editor, 2 = bottom panel top,
* 0 = none. The sidebar owns the left corner; the panel splitter is only in
* the editor/right region. The sizes are clamped cached values, so hit-tests
* match what is painted. */
static int axyne_splitter_at(const AxyneWindowState *state, int x, int y)
{
int status_top = state->client_height - AXYNE_STATUS;
int panel = axyne_panel_height(state);
if (x < 0 || x >= state->client_width) return 0;
if (axyne_layout_on_panel_splitter(y, status_top - panel, panel)) return 2;
if (x >= axyne_sidebar_width(state) &&
axyne_layout_on_panel_splitter(y, status_top - panel, panel)) return 2;
if (y >= AXYNE_UI_MENU + AXYNE_UI_TOOLBAR + AXYNE_UI_TABS && y < status_top &&
axyne_layout_on_sidebar_splitter(x, axyne_sidebar_width(state)))
return 1;
Expand Down
151 changes: 146 additions & 5 deletions tests/test_workspace_layout_macos.m
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ - (void)moveSelectionBy:(NSInteger)delta;
- (NSInteger)rowIndexAtPoint:(NSPoint)point;
- (BOOL)pointIsInOutline:(NSPoint)point;
- (NSInteger)outlineSymbolAtPoint:(NSPoint)point;
- (CGFloat)sidebarWidth;
- (CGFloat)panelHeight;
- (CGFloat)outlineHeight;
- (CGFloat)explorerTreeBottom;
- (NSInteger)explorerVisibleRows;
- (void)scrollWheel:(NSEvent *)event;
- (NSInteger)splitterAtPoint:(NSPoint)point;
- (NSRect)splitterRect:(NSInteger)which;
- (void)togglePanel:(id)sender;
- (void)selectSidebarTab:(NSInteger)tab;
@end

static id field(id view, const char *name)
Expand All @@ -54,6 +64,29 @@ static id field(id view, const char *name)
return ivar == NULL ? NULL : (char *)view + ivar_getOffset(ivar);
}

/* The fixture has no window. Supply deterministic window coordinates and
* line deltas while exercising the production scrollWheel: handler. */
@interface AxyneLayoutScrollEvent : NSEvent {
@public
NSPoint testLocation;
CGFloat testDelta;
}
@end
@implementation AxyneLayoutScrollEvent
- (NSPoint)locationInWindow { return testLocation; }
- (CGFloat)scrollingDeltaY { return testDelta; }
- (CGFloat)scrollingDeltaX { return 0; }
- (BOOL)hasPreciseScrollingDeltas { return NO; }
@end

static NSEvent *scroll_event(NSView *view, NSPoint point, CGFloat delta_y)
{
AxyneLayoutScrollEvent *event = [[[AxyneLayoutScrollEvent alloc] init] autorelease];
event->testLocation = [view convertPoint:point toView:nil];
event->testDelta = delta_y;
return event;
}

/* Failure diagnostics: every failed check dumps the document set, the tab
* frames and the explorer rows, so a CI log shows the actual state. */
static NSView *diagnosticView;
Expand Down Expand Up @@ -342,7 +375,7 @@ int main(void)
[view layoutSubtreeIfNeeded];
CGFloat top = AXYNE_UI_MENU + AXYNE_UI_TOOLBAR + AXYNE_UI_TABS +
AXYNE_UI_EXPLORER_HEADER;
CGFloat bottom = 842 - AXYNE_UI_STATUS - AXYNE_UI_PANEL;
CGFloat bottom = 842 - AXYNE_UI_STATUS;
CHECK_ROW(NSMakePoint(80, top), 0);
CHECK_ROW(NSMakePoint(80, top + AXYNE_UI_ROW - 1), 0);
CHECK_ROW(NSMakePoint(80, top + AXYNE_UI_ROW), 1);
Expand All @@ -356,12 +389,20 @@ int main(void)
fprintf(stderr, "Checking explorer outline\n");
{
AxyneOutline *outline = value_field(view, "_outline");
const char outlineText[] = "int a;\nint b;\nint f(void) { return 0; }\n"
"struct S { int x; };\n";
const char outlineText[] = "int a;\nint b;\nint f00(void) { return 0; }\n"
"int f01(void) { return 1; }\nint f02(void) { return 2; }\n"
"int f03(void) { return 3; }\nint f04(void) { return 4; }\n"
"int f05(void) { return 5; }\nint f06(void) { return 6; }\n"
"int f07(void) { return 7; }\nint f08(void) { return 8; }\n"
"int f09(void) { return 9; }\nint f10(void) { return 10; }\n"
"int f11(void) { return 11; }\nint f12(void) { return 12; }\n"
"int f13(void) { return 13; }\nint f14(void) { return 14; }\n"
"int f15(void) { return 15; }\nstruct S { int x; };\n";
CGFloat explorerHeight = bottom - AXYNE_UI_MENU - AXYNE_UI_TOOLBAR - AXYNE_UI_TABS;
int section;
CGFloat treeBottom;
NSInteger lastRow;
size_t outlineRows;
CHECK(outline != NULL);
/* No document is open: the section is hidden. */
CHECK(outline->state == AXYNE_OUTLINE_HIDDEN);
Expand All @@ -372,10 +413,12 @@ int main(void)
CHECK(outline->symbols.count >= 3);
[view setNeedsLayout:YES]; [view layoutSubtreeIfNeeded];
section = axyne_outline_height(outline, (int)explorerHeight);
outlineRows = outline->symbols.count < AXYNE_OUTLINE_DEFAULT_ROWS
? outline->symbols.count : AXYNE_OUTLINE_DEFAULT_ROWS;
CHECK(section == AXYNE_OUTLINE_SEPARATOR + AXYNE_OUTLINE_HEADER +
(int)outline->symbols.count * AXYNE_OUTLINE_ROW);
(int)outlineRows * AXYNE_OUTLINE_ROW);
CHECK(section <= explorerHeight * 0.4);
treeBottom = bottom - section;
treeBottom = 842 - AXYNE_UI_STATUS - section;
lastRow = (NSInteger)((treeBottom - top) / AXYNE_UI_ROW) - 1;
CHECK(lastRow > 0);
CHECK_ROW(NSMakePoint(80, top + lastRow * AXYNE_UI_ROW), lastRow);
Expand All @@ -395,6 +438,104 @@ int main(void)
axyne_outline_clear(outline);
[view setNeedsLayout:YES]; [view layoutSubtreeIfNeeded];
CHECK_ROW(NSMakePoint(80, top + (lastRow + 1) * AXYNE_UI_ROW), lastRow + 1);
CHECK(axyne_outline_prepare(outline, "main.c", sizeof(outlineText) - 1) != 0);
CHECK(axyne_outline_scan(outline, outlineText, sizeof(outlineText) - 1, NULL) ==
AXYNE_STATUS_OK);
[view setNeedsLayout:YES]; [view layoutSubtreeIfNeeded];
}

/* The sidebar is independent of the bottom panel. Resizing and hiding
* the panel must leave the tree, outline, divider, and Git column at
* the status-bar top; only the editor/right panel moves. */
fprintf(stderr, "Checking sidebar/panel independence and splitters\n");
{
CGFloat statusTop = 842 - AXYNE_UI_STATUS;
CGFloat sidebar = [view sidebarWidth];
CGFloat outlineBefore = [view outlineHeight];
CGFloat treeBefore = [view explorerTreeBottom];
CGFloat *panelSize = value_field(view, "_panelSize");
NSRect sidebarSplitter = [view splitterRect:1];
NSRect panelSplitter = [view splitterRect:2];
CHECK(panelSize != NULL);
CHECK(treeBefore == statusTop - outlineBefore);
CHECK(NSMaxY(sidebarSplitter) == statusTop);
CHECK(NSMinX(panelSplitter) == sidebar);
CHECK(NSMaxX(panelSplitter) == 1440);
CHECK([view splitterAtPoint:NSMakePoint(sidebar - 1,
statusTop - [view panelHeight])] == 1);
CHECK([view splitterAtPoint:NSMakePoint(sidebar + 1,
statusTop - [view panelHeight])] == 2);
*panelSize = 150;
[view setNeedsLayout:YES]; [view layoutSubtreeIfNeeded];
CHECK([view panelHeight] == 150);
CHECK([view explorerTreeBottom] == treeBefore);
CHECK([view outlineHeight] == outlineBefore);
[view togglePanel:nil];
[view layoutSubtreeIfNeeded];
CHECK([view panelHeight] == 0);
CHECK([view explorerTreeBottom] == treeBefore);
CHECK([view outlineHeight] == outlineBefore);
NSInteger hiddenVisible = [view explorerVisibleRows];
CHECK(hiddenVisible == (NSInteger)((statusTop - outlineBefore - top) /
AXYNE_UI_ROW));
CHECK_ROW(NSMakePoint(80, top + (hiddenVisible - 1) * AXYNE_UI_ROW),
hiddenVisible - 1);
CHECK(NSMaxY([view splitterRect:1]) == statusTop);
CHECK([view splitterAtPoint:NSMakePoint(sidebar - 1, statusTop - 1)] == 1);
CHECK([view splitterAtPoint:NSMakePoint(sidebar + 1, statusTop - 1)] == 0);
[view togglePanel:nil];
[view layoutSubtreeIfNeeded];
CHECK([view explorerTreeBottom] == treeBefore);
CHECK([view outlineHeight] == outlineBefore);

[view selectSidebarTab:1];
[view layoutSubtreeIfNeeded];
NSView *gitPanel = field(view, "_gitPanel");
CHECK(gitPanel != nil && NSMinY([gitPanel frame]) ==
AXYNE_UI_MENU + AXYNE_UI_TOOLBAR + AXYNE_UI_TABS + AXYNE_UI_EXPLORER_HEADER);
CHECK(NSMaxY([gitPanel frame]) == statusTop);
[view selectSidebarTab:0];
[view layoutSubtreeIfNeeded];
}

/* With a large fixture, the last complete tree row remains clickable
* and tree/outline wheel ranges are based on the full sidebar. */
{
CGFloat statusTop = 842 - AXYNE_UI_STATUS;
NSInteger visible = [view explorerVisibleRows];
CGFloat treeBottom = [view explorerTreeBottom];
CHECK(visible == (NSInteger)((treeBottom - top) / AXYNE_UI_ROW));
CHECK_ROW(NSMakePoint(80, top + (visible - 1) * AXYNE_UI_ROW),
visible - 1);
CHECK_ROW(NSMakePoint(80, treeBottom), NSNotFound);
CHECK([view outlineSymbolAtPoint:NSMakePoint(80,
treeBottom + AXYNE_OUTLINE_SEPARATOR + AXYNE_OUTLINE_HEADER + 1)] >= 0);
CHECK([view outlineSymbolAtPoint:NSMakePoint(80, statusTop)] == -1);

/* Exercise actual wheel handling, not only the computed ranges. */
NSInteger *firstRow = value_field(view, "_explorerFirstRow");
AxyneOutline *outline = value_field(view, "_outline");
CHECK(firstRow != NULL && outline != NULL);
*firstRow = 0;
outline->first_row = 0;
NSEvent *wheel = scroll_event(view, NSMakePoint(80, top + AXYNE_UI_ROW), -3);
CHECK(wheel != nil);
[view scrollWheel:wheel];
CHECK(*firstRow > 0);
wheel = scroll_event(view, NSMakePoint(80, top + AXYNE_UI_ROW), 3);
CHECK(wheel != nil);
[view scrollWheel:wheel];
CHECK(*firstRow == 0);
wheel = scroll_event(view, NSMakePoint(80, treeBottom +
AXYNE_OUTLINE_SEPARATOR + AXYNE_OUTLINE_HEADER + AXYNE_OUTLINE_ROW), -3);
CHECK(wheel != nil);
[view scrollWheel:wheel];
CHECK(outline->first_row > 0);
wheel = scroll_event(view, NSMakePoint(80, treeBottom +
AXYNE_OUTLINE_SEPARATOR + AXYNE_OUTLINE_HEADER + AXYNE_OUTLINE_ROW), 3);
CHECK(wheel != nil);
[view scrollWheel:wheel];
CHECK(outline->first_row == 0);
}

/* The command palette replaces the toolbar search field while open. */
Expand Down
Loading