Skip to content

Commit bc50402

Browse files
committed
fix: portal positioning, table hover/scroll, tree right-click, cursor in nested containers
- PortalPositioner: shift portal up before truncating when near bottom edge - TableControl: fix last row invisible when filtering enabled without border - TableControl: clear hover on scroll offset change, data reset, and ClearRows - TreeControl: add LastRightClickedNode property for right-click without selection change - TreeControl: add HoverEnabled property to disable hover highlighting - ColumnContainer: implement ILogicalCursorProvider to propagate cursor position - HorizontalGridControl: add column offset to cursor position translation - ScrollablePanelControl: implement ILogicalCursorProvider with scroll offset
1 parent 787d9e8 commit bc50402

8 files changed

Lines changed: 126 additions & 25 deletions

File tree

SharpConsoleUI/Controls/ColumnContainer.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ namespace SharpConsoleUI.Controls
2121
/// A container control that holds child controls vertically within a column of a <see cref="HorizontalGridControl"/>.
2222
/// Supports layout constraints, focus management, and dynamic content sizing.
2323
/// </summary>
24-
public class ColumnContainer : IContainer, IInteractiveControl, IFocusableControl, IMouseAwareControl, ILayoutAware, IDOMPaintable, IContainerControl
24+
public class ColumnContainer : IContainer, IInteractiveControl, IFocusableControl, IMouseAwareControl, ILayoutAware, IDOMPaintable, IContainerControl, ILogicalCursorProvider
2525
{
2626
private HorizontalAlignment _horizontalAlignment = HorizontalAlignment.Left;
2727
private VerticalAlignment _verticalAlignment = VerticalAlignment.Fill;
@@ -651,6 +651,24 @@ public bool ProcessKey(ConsoleKeyInfo key)
651651
/// </summary>
652652
public bool CanReceiveFocus => false;
653653

654+
/// <inheritdoc/>
655+
public System.Drawing.Point? GetLogicalCursorPosition()
656+
{
657+
var focusManager = (this as IWindowControl).GetParentWindow()?.FocusManager;
658+
var focused = GetInteractiveContents()
659+
.FirstOrDefault(c => c is IFocusableControl fc && (focusManager?.IsFocused(fc) ?? false));
660+
return (focused as ILogicalCursorProvider)?.GetLogicalCursorPosition();
661+
}
662+
663+
/// <inheritdoc/>
664+
public void SetLogicalCursorPosition(System.Drawing.Point position)
665+
{
666+
var focusManager = (this as IWindowControl).GetParentWindow()?.FocusManager;
667+
var focused = GetInteractiveContents()
668+
.FirstOrDefault(c => c is IFocusableControl fc && (focusManager?.IsFocused(fc) ?? false));
669+
(focused as ILogicalCursorProvider)?.SetLogicalCursorPosition(position);
670+
}
671+
654672
/// <inheritdoc/>
655673
public System.Drawing.Size GetLogicalContentSize()
656674
{

SharpConsoleUI/Controls/HorizontalGridControl/HorizontalGridControl.Layout.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,18 @@ public IReadOnlyList<IWindowControl> GetChildren()
8686

8787
if (childPosition.HasValue && focusedContent is IWindowControl focusedControl)
8888
{
89-
// For now, just return child position as-is
90-
// The proper offset will be accumulated in TranslateLogicalCursorToWindow
89+
// Find the column containing the focused control and add its offset
90+
List<ColumnContainer> columns;
91+
lock (_gridLock) { columns = new List<ColumnContainer>(_columns); }
92+
foreach (var col in columns)
93+
{
94+
if (col.Contents.Contains(focusedControl))
95+
{
96+
int offsetX = col.ActualX - ActualX;
97+
int offsetY = col.ActualY - ActualY;
98+
return new Point(childPosition.Value.X + offsetX, childPosition.Value.Y + offsetY);
99+
}
100+
}
91101
}
92102

93103
return childPosition;

SharpConsoleUI/Controls/ScrollablePanelControl/ScrollablePanelControl.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace SharpConsoleUI.Controls
1818
/// A scrollable panel control that can host child controls with automatic scrolling support.
1919
/// Supports vertical and horizontal scrolling, mouse wheel, and visual scrollbars.
2020
/// </summary>
21-
public partial class ScrollablePanelControl : BaseControl, IInteractiveControl, IFocusableControl, IMouseAwareControl, IContainer, IContainerControl, IScrollableContainer, IFocusScope
21+
public partial class ScrollablePanelControl : BaseControl, IInteractiveControl, IFocusableControl, IMouseAwareControl, IContainer, IContainerControl, IScrollableContainer, IFocusScope, ILogicalCursorProvider
2222
{
2323
private readonly List<IWindowControl> _children = new();
2424
private readonly object _childrenLock = new();
@@ -405,6 +405,36 @@ public bool IsEnabled
405405
return null;
406406
}
407407

408+
/// <inheritdoc/>
409+
public System.Drawing.Point? GetLogicalCursorPosition()
410+
{
411+
var focused = GetFocusedChildFromCoordinator();
412+
if (focused is ILogicalCursorProvider cursorProvider)
413+
{
414+
var childPos = cursorProvider.GetLogicalCursorPosition();
415+
if (childPos.HasValue && focused is IWindowControl wc)
416+
{
417+
return new System.Drawing.Point(
418+
childPos.Value.X + wc.ActualX - ActualX,
419+
childPos.Value.Y + wc.ActualY - ActualY - _verticalScrollOffset);
420+
}
421+
}
422+
return null;
423+
}
424+
425+
/// <inheritdoc/>
426+
public void SetLogicalCursorPosition(System.Drawing.Point position)
427+
{
428+
var focused = GetFocusedChildFromCoordinator();
429+
if (focused is ILogicalCursorProvider cursorProvider && focused is IWindowControl wc)
430+
{
431+
var childPos = new System.Drawing.Point(
432+
position.X - wc.ActualX + ActualX,
433+
position.Y - wc.ActualY + ActualY + _verticalScrollOffset);
434+
cursorProvider.SetLogicalCursorPosition(childPos);
435+
}
436+
}
437+
408438
#endregion
409439

410440
#region IContainer Implementation

SharpConsoleUI/Controls/TableControl/TableControl.Scrolling.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ public int ScrollOffset
2424
set
2525
{
2626
int maxOffset = Math.Max(0, RowCount - GetVisibleRowCount());
27-
_scrollOffset = Math.Clamp(value, 0, maxOffset);
28-
OnPropertyChanged();
29-
Container?.Invalidate(true);
27+
int clamped = Math.Clamp(value, 0, maxOffset);
28+
if (clamped != _scrollOffset)
29+
{
30+
_scrollOffset = clamped;
31+
_hoveredRowIndex = -1;
32+
OnPropertyChanged();
33+
Container?.Invalidate(true);
34+
}
3035
}
3136
}
3237

@@ -104,8 +109,8 @@ private int CalculateVisibleRowsFromHeight(int totalHeight)
104109
if (ShouldShowHorizontalScrollbar())
105110
usedHeight++;
106111

107-
// Reserve space for filter status bar (separator + status row)
108-
if (_filteringEnabled && !_readOnly)
112+
// Reserve space for filter status bar (separator + status row) — only rendered with borders
113+
if (_filteringEnabled && !_readOnly && hasBorder)
109114
usedHeight += 2;
110115

111116
int availableLines = Math.Max(0, totalHeight - usedHeight);
@@ -125,6 +130,7 @@ internal void EnsureSelectedRowVisible()
125130
if (_selectedRowIndex < 0) return;
126131

127132
int visibleRows = GetVisibleRowCount();
133+
int oldOffset = _scrollOffset;
128134

129135
if (_selectedRowIndex < _scrollOffset)
130136
{
@@ -135,6 +141,9 @@ internal void EnsureSelectedRowVisible()
135141
_scrollOffset = _selectedRowIndex - visibleRows + 1;
136142
}
137143

144+
if (_scrollOffset != oldOffset)
145+
_hoveredRowIndex = -1;
146+
138147
// Clamp
139148
int maxOffset = Math.Max(0, RowCount - visibleRows);
140149
_scrollOffset = Math.Clamp(_scrollOffset, 0, maxOffset);

SharpConsoleUI/Controls/TableControl/TableControl.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ public ITableDataSource? DataSource
383383
_measurementCache.InvalidateCache();
384384
_selectedRowIndex = -1;
385385
_selectedColumnIndex = -1;
386+
_hoveredRowIndex = -1;
386387
_scrollOffset = 0;
387388
_horizontalScrollOffset = 0;
388389
_sortColumnIndex = -1;
@@ -402,6 +403,7 @@ private void OnDataSourceCollectionChanged(object? sender, NotifyCollectionChang
402403
{
403404
_selectedRowIndex = RowCount > 0 ? 0 : -1;
404405
_selectedRowIndices.Clear();
406+
_hoveredRowIndex = -1;
405407
_scrollOffset = 0;
406408
}
407409
InvalidateColumnWidths();
@@ -725,6 +727,7 @@ public void ClearRows()
725727
lock (_tableLock) { _rows.Clear(); }
726728
_selectedRowIndex = -1;
727729
_selectedColumnIndex = -1;
730+
_hoveredRowIndex = -1;
728731
_scrollOffset = 0;
729732
_horizontalScrollOffset = 0;
730733
_selectedRowIndices.Clear();

SharpConsoleUI/Controls/TreeControl/TreeControl.Mouse.cs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,18 @@ public bool ProcessMouseEvent(MouseEventArgs args)
153153
bool mouseOnScrollbar = IsClickOnScrollbar(args.Position.X, sbContentWidth);
154154

155155
// Update hover state (only when mouse is over content, not scrollbar)
156-
if (!mouseOnScrollbar && nodeIndex != _hoveredIndex)
156+
if (_hoverEnabled)
157157
{
158-
_hoveredIndex = nodeIndex;
159-
Container?.Invalidate(true);
160-
}
161-
else if (mouseOnScrollbar && _hoveredIndex != -1)
162-
{
163-
_hoveredIndex = -1;
164-
Container?.Invalidate(true);
158+
if (!mouseOnScrollbar && nodeIndex != _hoveredIndex)
159+
{
160+
_hoveredIndex = nodeIndex;
161+
Container?.Invalidate(true);
162+
}
163+
else if (mouseOnScrollbar && _hoveredIndex != -1)
164+
{
165+
_hoveredIndex = -1;
166+
Container?.Invalidate(true);
167+
}
165168
}
166169

167170
// Handle mouse wheel scrolling
@@ -199,10 +202,18 @@ public bool ProcessMouseEvent(MouseEventArgs args)
199202
// Handle right-click
200203
if (args.HasFlag(MouseFlags.Button3Clicked))
201204
{
202-
if (!mouseOnScrollbar && _selectOnRightClick && nodeIndex >= 0 && nodeIndex < _flattenedNodes.Count)
205+
if (!mouseOnScrollbar && nodeIndex >= 0 && nodeIndex < _flattenedNodes.Count)
203206
{
204-
SelectNodeNoScroll(nodeIndex);
205-
Container?.Invalidate(true);
207+
_lastRightClickedNode = _flattenedNodes[nodeIndex];
208+
if (_selectOnRightClick)
209+
{
210+
SelectNodeNoScroll(nodeIndex);
211+
Container?.Invalidate(true);
212+
}
213+
}
214+
else
215+
{
216+
_lastRightClickedNode = null;
206217
}
207218
fireMouseRightClick = args;
208219
result = true;

SharpConsoleUI/Controls/TreeControl/TreeControl.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ public partial class TreeControl : BaseControl, IInteractiveControl, IFocusableC
5050

5151
// Mouse interaction state
5252
private int _hoveredIndex = -1;
53+
private bool _hoverEnabled = true;
5354
private bool _selectOnRightClick = false;
55+
private TreeNode? _lastRightClickedNode;
5456
private readonly object _clickLock = new object();
5557
private DateTime _lastClickTime = DateTime.MinValue;
5658
private int _lastClickIndex = -1;
@@ -208,6 +210,21 @@ public bool SelectOnRightClick
208210
set { _selectOnRightClick = value; OnPropertyChanged(); }
209211
}
210212

213+
/// <summary>
214+
/// Gets or sets whether mouse hover highlighting is enabled. Default: true.
215+
/// </summary>
216+
public bool HoverEnabled
217+
{
218+
get => _hoverEnabled;
219+
set { _hoverEnabled = value; if (!value) _hoveredIndex = -1; OnPropertyChanged(); Container?.Invalidate(true); }
220+
}
221+
222+
/// <summary>
223+
/// Gets the node that was under the cursor during the most recent right-click.
224+
/// Set before <see cref="MouseRightClick"/> fires, regardless of <see cref="SelectOnRightClick"/>.
225+
/// </summary>
226+
public TreeNode? LastRightClickedNode => _lastRightClickedNode;
227+
211228
/// <inheritdoc/>
212229
public ScrollbarVisibility ScrollbarVisibility
213230
{

SharpConsoleUI/Layout/PortalPositioner.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,22 @@ public static PortalPositionResult Calculate(PortalPositionRequest request)
135135
wasClamped = true;
136136
}
137137

138-
// Clamp vertical
138+
// Clamp vertical — shift before truncating so the portal stays fully visible when possible
139139
if (y + height > screen.Bottom)
140140
{
141-
height = screen.Bottom - y;
141+
int shift = y + height - screen.Bottom;
142+
y -= shift;
142143
wasClamped = true;
143144
}
144145
if (y < screen.Y)
145146
{
146-
int overflow = screen.Y - y;
147-
height -= overflow;
148147
y = screen.Y;
149148
wasClamped = true;
150149
}
150+
if (y + height > screen.Bottom)
151+
{
152+
height = screen.Bottom - y;
153+
}
151154

152155
// Ensure non-negative dimensions
153156
width = Math.Max(0, width);
@@ -175,7 +178,7 @@ public static PortalPositionResult CalculateFromPoint(
175178
PortalPlacement placement = PortalPlacement.BelowOrAbove,
176179
Size minSize = default)
177180
{
178-
// Point anchor → zero-width, 1-height rect (cursor occupies 1 row)
181+
// Point anchor → 1-height rect (cursor occupies 1 row, portal opens below/above it)
179182
var anchorRect = new Rectangle(anchor.X, anchor.Y, 0, 1);
180183
var result = Calculate(new PortalPositionRequest(anchorRect, contentSize, screenBounds, placement));
181184

0 commit comments

Comments
 (0)