From 32b7416531b7638fbd161d372919c74792a94b3b Mon Sep 17 00:00:00 2001 From: soyuo Date: Sun, 11 Oct 2026 17:18:12 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=83=90=EC=83=89=EA=B8=B0=EC=99=80=20?= =?UTF-8?q?=EA=B0=9C=EC=9A=94=20=EB=86=92=EC=9D=B4=EB=A5=BC=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=20=ED=8C=A8=EB=84=90=EC=97=90=EC=84=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/sidebar-panel-layout.md | 39 +++++ src/features/ui/platform/macos/ui_macos.m | 32 ++-- src/features/ui/platform/windows/ui_windows.c | 10 +- tests/test_workspace_layout_macos.m | 151 +++++++++++++++++- 4 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 docs/specs/sidebar-panel-layout.md diff --git a/docs/specs/sidebar-panel-layout.md b/docs/specs/sidebar-panel-layout.md new file mode 100644 index 0000000..d40ac1e --- /dev/null +++ b/docs/specs/sidebar-panel-layout.md @@ -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. diff --git a/src/features/ui/platform/macos/ui_macos.m b/src/features/ui/platform/macos/ui_macos.m index 52e611b..637ea98 100644 --- a/src/features/ui/platform/macos/ui_macos.m +++ b/src/features/ui/platform/macos/ui_macos.m @@ -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); @@ -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; } @@ -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)); } @@ -4164,8 +4164,9 @@ - (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]; @@ -4173,7 +4174,8 @@ - (NSInteger)splitterAtPoint:(NSPoint)point 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])) @@ -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; @@ -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. */ @@ -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; } @@ -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, @@ -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; @@ -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]; diff --git a/src/features/ui/platform/windows/ui_windows.c b/src/features/ui/platform/windows/ui_windows.c index 97d924c..f55e1e2 100644 --- a/src/features/ui/platform/windows/ui_windows.c +++ b/src/features/ui/platform/windows/ui_windows.c @@ -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; diff --git a/tests/test_workspace_layout_macos.m b/tests/test_workspace_layout_macos.m index c922c34..da300a4 100644 --- a/tests/test_workspace_layout_macos.m +++ b/tests/test_workspace_layout_macos.m @@ -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) @@ -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; @@ -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); @@ -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); @@ -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); @@ -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. */