Skip to content

Commit 0707c76

Browse files
committed
Add scrollbar interactivity and focus highlighting to MultilineEditControl
- Focus-aware scrollbar colors matching ScrollablePanelControl (Cyan1/Grey/Grey23) - Mouse drag support for vertical and horizontal scrollbar thumbs - Clickable arrow indicators at scrollbar edges and page scroll on track click - Fix horizontal scrollbar visibility using effective width after gutter/scrollbar - Prevent viewport snap-back to cursor during manual scroll interactions
1 parent 191235d commit 0707c76

4 files changed

Lines changed: 344 additions & 24 deletions

File tree

SharpConsoleUI/Controls/MultilineEdit/MultilineEditControl.Keyboard.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public bool ProcessKey(ConsoleKeyInfo key)
2727
{
2828
if (!_isEnabled) return false;
2929

30+
// Any keyboard action clears manual scroll override so cursor-follow resumes
31+
_scrollbarInteracted = false;
32+
3033
// When focused but not editing, only handle specific navigation keys
3134
// All other keys (including Ctrl/Alt/Shift combinations) bubble up
3235
if (_hasFocus && !_isEditing)

SharpConsoleUI/Controls/MultilineEdit/MultilineEditControl.Mouse.cs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,191 @@ namespace SharpConsoleUI.Controls
1515
{
1616
public partial class MultilineEditControl
1717
{
18+
/// <summary>
19+
/// Tests whether a mouse position falls on the vertical scrollbar column.
20+
/// </summary>
21+
private bool IsOnVerticalScrollbar(int mouseX)
22+
{
23+
if (!_needsVerticalScrollbar) return false;
24+
int scrollbarX = _margin.Left + GetGutterWidth() + _effectiveWidth;
25+
return mouseX == scrollbarX;
26+
}
27+
28+
/// <summary>
29+
/// Tests whether a mouse position falls on the horizontal scrollbar row.
30+
/// </summary>
31+
private bool IsOnHorizontalScrollbar(int mouseY)
32+
{
33+
if (!_needsHorizontalScrollbar) return false;
34+
int scrollbarY = _margin.Top + _effectiveViewportHeight;
35+
return mouseY == scrollbarY;
36+
}
37+
1838
/// <inheritdoc/>
1939
public bool ProcessMouseEvent(MouseEventArgs args)
2040
{
2141
if (!IsEnabled || !WantsMouseEvents)
2242
return false;
2343

44+
// --- Scrollbar drag-in-progress (must be checked before text drag) ---
45+
46+
if (_isVerticalScrollbarDragging &&
47+
args.HasAnyFlag(MouseFlags.Button1Dragged, MouseFlags.Button1Pressed))
48+
{
49+
var (trackHeight, _, sbThumbHeight) = GetVerticalScrollbarGeometry();
50+
int deltaY = args.Position.Y - _verticalScrollbarDragStartY;
51+
int totalLines = GetTotalWrappedLineCount();
52+
int effectiveViewport = GetEffectiveViewportHeight();
53+
int maxScroll = Math.Max(0, totalLines - effectiveViewport);
54+
int trackRange = Math.Max(1, trackHeight - sbThumbHeight);
55+
int newOffset = _verticalScrollbarDragStartOffset +
56+
(int)(deltaY * (double)maxScroll / trackRange);
57+
newOffset = Math.Clamp(newOffset, 0, maxScroll);
58+
_skipUpdateScrollPositionsInRender = true;
59+
_verticalScrollOffset = newOffset;
60+
Container?.Invalidate(true);
61+
return true;
62+
}
63+
64+
if (_isHorizontalScrollbarDragging &&
65+
args.HasAnyFlag(MouseFlags.Button1Dragged, MouseFlags.Button1Pressed))
66+
{
67+
var (trackWidth, _, sbThumbWidth) = GetHorizontalScrollbarGeometry();
68+
int deltaX = args.Position.X - _horizontalScrollbarDragStartX;
69+
int maxLineLength = GetMaxLineLength();
70+
int maxScroll = Math.Max(0, maxLineLength - _effectiveWidth);
71+
int trackRange = Math.Max(1, trackWidth - sbThumbWidth);
72+
int newOffset = _horizontalScrollbarDragStartOffset +
73+
(int)(deltaX * (double)maxScroll / trackRange);
74+
newOffset = Math.Clamp(newOffset, 0, maxScroll);
75+
_horizontalScrollOffset = newOffset;
76+
Container?.Invalidate(true);
77+
return true;
78+
}
79+
80+
// --- Scrollbar drag end ---
81+
82+
if (args.HasFlag(MouseFlags.Button1Released))
83+
{
84+
if (_isVerticalScrollbarDragging || _isHorizontalScrollbarDragging)
85+
{
86+
_isVerticalScrollbarDragging = false;
87+
_isHorizontalScrollbarDragging = false;
88+
return true;
89+
}
90+
}
91+
92+
// --- Scrollbar click detection (before text handling) ---
93+
94+
if (args.HasFlag(MouseFlags.Button1Pressed) && _hasFocus)
95+
{
96+
// Vertical scrollbar interaction
97+
if (IsOnVerticalScrollbar(args.Position.X))
98+
{
99+
_scrollbarInteracted = true;
100+
int relY = args.Position.Y - _margin.Top;
101+
var (trackHeight, sbThumbY, sbThumbHeight) = GetVerticalScrollbarGeometry();
102+
int effectiveViewport = trackHeight;
103+
int totalLines = GetTotalWrappedLineCount();
104+
int maxScroll = Math.Max(0, totalLines - effectiveViewport);
105+
106+
if (relY >= 0 && relY < trackHeight)
107+
{
108+
if (relY == 0 && _verticalScrollOffset > 0)
109+
{
110+
// Arrow up
111+
_skipUpdateScrollPositionsInRender = true;
112+
_verticalScrollOffset = Math.Max(0, _verticalScrollOffset - ControlDefaults.DefaultScrollWheelLines);
113+
Container?.Invalidate(true);
114+
}
115+
else if (relY == trackHeight - 1 && _verticalScrollOffset < maxScroll)
116+
{
117+
// Arrow down
118+
_skipUpdateScrollPositionsInRender = true;
119+
_verticalScrollOffset = Math.Min(maxScroll, _verticalScrollOffset + ControlDefaults.DefaultScrollWheelLines);
120+
Container?.Invalidate(true);
121+
}
122+
else if (relY >= sbThumbY && relY < sbThumbY + sbThumbHeight)
123+
{
124+
// Thumb: start drag
125+
_isVerticalScrollbarDragging = true;
126+
_verticalScrollbarDragStartY = args.Position.Y;
127+
_verticalScrollbarDragStartOffset = _verticalScrollOffset;
128+
}
129+
else if (relY < sbThumbY)
130+
{
131+
// Track above thumb: page up
132+
_skipUpdateScrollPositionsInRender = true;
133+
_verticalScrollOffset = Math.Max(0, _verticalScrollOffset - effectiveViewport);
134+
Container?.Invalidate(true);
135+
}
136+
else
137+
{
138+
// Track below thumb: page down
139+
_skipUpdateScrollPositionsInRender = true;
140+
_verticalScrollOffset = Math.Min(maxScroll, _verticalScrollOffset + effectiveViewport);
141+
Container?.Invalidate(true);
142+
}
143+
return true;
144+
}
145+
}
146+
147+
// Horizontal scrollbar interaction
148+
if (IsOnHorizontalScrollbar(args.Position.Y))
149+
{
150+
_scrollbarInteracted = true;
151+
int relX = args.Position.X - _margin.Left - GetGutterWidth();
152+
var (trackWidth, sbThumbX, sbThumbWidth) = GetHorizontalScrollbarGeometry();
153+
int maxLineLength = GetMaxLineLength();
154+
int maxScroll = Math.Max(0, maxLineLength - _effectiveWidth);
155+
156+
if (relX >= 0 && relX < trackWidth)
157+
{
158+
if (relX == 0 && _horizontalScrollOffset > 0)
159+
{
160+
// Arrow left
161+
_horizontalScrollOffset = Math.Max(0, _horizontalScrollOffset - ControlDefaults.DefaultScrollWheelLines);
162+
Container?.Invalidate(true);
163+
}
164+
else if (relX == trackWidth - 1 && _horizontalScrollOffset < maxScroll)
165+
{
166+
// Arrow right
167+
_horizontalScrollOffset = Math.Min(maxScroll, _horizontalScrollOffset + ControlDefaults.DefaultScrollWheelLines);
168+
Container?.Invalidate(true);
169+
}
170+
else if (relX >= sbThumbX && relX < sbThumbX + sbThumbWidth)
171+
{
172+
// Thumb: start drag
173+
_isHorizontalScrollbarDragging = true;
174+
_horizontalScrollbarDragStartX = args.Position.X;
175+
_horizontalScrollbarDragStartOffset = _horizontalScrollOffset;
176+
}
177+
else if (relX < sbThumbX)
178+
{
179+
// Track left of thumb: page left
180+
_horizontalScrollOffset = Math.Max(0, _horizontalScrollOffset - _effectiveWidth);
181+
Container?.Invalidate(true);
182+
}
183+
else
184+
{
185+
// Track right of thumb: page right
186+
_horizontalScrollOffset = Math.Min(maxScroll, _horizontalScrollOffset + _effectiveWidth);
187+
Container?.Invalidate(true);
188+
}
189+
return true;
190+
}
191+
}
192+
}
193+
194+
// Consume other mouse events on scrollbar areas to prevent text cursor positioning
195+
if (args.HasAnyFlag(MouseFlags.Button1Clicked, MouseFlags.Button1Released,
196+
MouseFlags.Button1DoubleClicked, MouseFlags.Button1TripleClicked,
197+
MouseFlags.Button1Dragged))
198+
{
199+
if (IsOnVerticalScrollbar(args.Position.X) || IsOnHorizontalScrollbar(args.Position.Y))
200+
return true;
201+
}
202+
24203
// Triple-click: select entire line
25204
if (args.HasFlag(MouseFlags.Button1TripleClicked))
26205
{
@@ -93,6 +272,7 @@ public bool ProcessMouseEvent(MouseEventArgs args)
93272
else
94273
{
95274
// Fresh press — anchor the selection start
275+
_scrollbarInteracted = false;
96276
_hasSelection = true;
97277
_selectionStartX = _cursorX;
98278
_selectionStartY = _cursorY;
@@ -146,6 +326,7 @@ public bool ProcessMouseEvent(MouseEventArgs args)
146326
int scrollAmount = Math.Min(ControlDefaults.DefaultScrollWheelLines, _verticalScrollOffset);
147327
if (scrollAmount > 0)
148328
{
329+
_scrollbarInteracted = true;
149330
_skipUpdateScrollPositionsInRender = true;
150331
_verticalScrollOffset -= scrollAmount;
151332
Container?.Invalidate(true);
@@ -161,6 +342,7 @@ public bool ProcessMouseEvent(MouseEventArgs args)
161342
int scrollAmount = Math.Min(ControlDefaults.DefaultScrollWheelLines, maxScroll - _verticalScrollOffset);
162343
if (scrollAmount > 0)
163344
{
345+
_scrollbarInteracted = true;
164346
_skipUpdateScrollPositionsInRender = true;
165347
_verticalScrollOffset += scrollAmount;
166348
Container?.Invalidate(true);
@@ -213,6 +395,7 @@ private void PositionCursorFromMouseCore(int mouseX, int mouseY)
213395
/// </summary>
214396
private void PositionCursorFromMouse(int mouseX, int mouseY)
215397
{
398+
_scrollbarInteracted = false;
216399
PositionCursorFromMouseCore(mouseX, mouseY);
217400
ClearSelection();
218401
EnsureCursorVisible();

0 commit comments

Comments
 (0)