Skip to content

Commit 229df37

Browse files
committed
Fix DropdownControl width, hit-testing, and portal dismissal; fix DatePicker hit-test
- Replace calculateOptimalWidth with calculateHeaderWidth (StripLength-based, no magic constants) and calculatePortalWidth (portal sized independently from header) - Arrow rendered flush-right in header with padding between text and arrow - Hit-test checks actual visual content bounds, not full layout allocation - Enable DismissOnOutsideClick on dropdown portal for proper close on background click - Apply same hit-test fix to DatePickerControl with _lastContentWidth caching - Add NavigationView content toolbar support with theme switcher in launcher - Add gradient background to DropdownWindow demo
1 parent cee39b3 commit 229df37

11 files changed

Lines changed: 247 additions & 108 deletions

File tree

Examples/DemoApp/DemoWindows/DropdownWindow.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using SharpConsoleUI;
22
using SharpConsoleUI.Builders;
33
using SharpConsoleUI.Controls;
4+
using SharpConsoleUI.Helpers;
45
using SharpConsoleUI.Layout;
6+
using SharpConsoleUI.Rendering;
57

68
namespace DemoApp.DemoWindows;
79

@@ -145,6 +147,9 @@ void UpdateSummary(object? s, object? e)
145147
.WithTitle("Meal Planner")
146148
.WithSize(WindowWidth, WindowHeight)
147149
.Centered()
150+
.WithBackgroundGradient(
151+
ColorGradient.FromColors(new Color(20, 15, 40), new Color(10, 30, 50)),
152+
GradientDirection.Vertical)
148153
.AddControls(header, grid, statusBar)
149154
.OnKeyPressed((sender, e) =>
150155
{

Examples/DemoApp/DemoWindows/LauncherWindow.cs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ public static class LauncherWindow
1111
{
1212
public static Window Create(ConsoleWindowSystem ws)
1313
{
14+
var darkGradient = new GradientBackground(
15+
ColorGradient.FromColors(new Color(15, 25, 60), new Color(5, 5, 15)),
16+
GradientDirection.Vertical);
17+
18+
var lightGradient = new GradientBackground(
19+
ColorGradient.FromColors(new Color(180, 200, 230), new Color(220, 225, 240)),
20+
GradientDirection.Vertical);
21+
1422
var nav = Controls.NavigationView()
1523
.WithNavWidth(30)
1624
.WithPaneHeader("[bold white] SharpConsoleUI[/]")
@@ -71,17 +79,28 @@ public static Window Create(ConsoleWindowSystem ws)
7179
LaunchDemo(ws, args.NewItem.Text);
7280
};
7381

74-
var gradient = ColorGradient.FromColors(
75-
new Color(15, 25, 60),
76-
new Color(5, 5, 15));
82+
// Theme switcher dropdown in content toolbar
83+
var themeDropdown = new DropdownControl("Theme:", new[] { "Dark", "Light" });
84+
themeDropdown.SelectedIndex = 0;
85+
nav.AddContentToolbarItem(themeDropdown);
86+
87+
Window? win = null;
88+
themeDropdown.SelectedIndexChanged += (_, idx) =>
89+
{
90+
if (win != null)
91+
win.BackgroundGradient = idx == 0 ? darkGradient : lightGradient;
92+
};
7793

78-
return new WindowBuilder(ws)
94+
var window = new WindowBuilder(ws)
7995
.WithTitle("SharpConsoleUI Demo")
8096
.WithSize(90, 30)
8197
.AtPosition(0, 0)
82-
.WithBackgroundGradient(gradient, GradientDirection.Vertical)
98+
.WithBackgroundGradient(darkGradient.Gradient, darkGradient.Direction)
8399
.AddControl(nav)
84100
.BuildAndShow();
101+
102+
win = window;
103+
return window;
85104
}
86105

87106
private static Action<ScrollablePanelControl> MakeInfoPanel(string demoName)

SharpConsoleUI/Builders/NavigationViewBuilder.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ public NavigationViewBuilder WithAnimateTransitions(bool animate)
235235

236236
#endregion
237237

238+
private Action<ToolbarControl>? _contentToolbarConfigure;
239+
238240
#region Content Pane Config
239241

240242
/// <summary>
@@ -282,6 +284,15 @@ public NavigationViewBuilder WithContentHeader(bool show)
282284
return this;
283285
}
284286

287+
/// <summary>
288+
/// Configures the content toolbar in the header area.
289+
/// </summary>
290+
public NavigationViewBuilder WithContentToolbar(Action<ToolbarControl> configure)
291+
{
292+
_contentToolbarConfigure = configure;
293+
return this;
294+
}
295+
285296
#endregion
286297

287298
#region Events
@@ -436,6 +447,13 @@ public NavigationView Build()
436447
}
437448
}
438449

450+
// Configure content toolbar
451+
if (_contentToolbarConfigure != null)
452+
{
453+
_contentToolbarConfigure(_control.ContentToolbar);
454+
_control.ContentToolbar.Visible = _control.ContentToolbar.Items.Any();
455+
}
456+
439457
// Set initial selection (count only selectable items)
440458
int selectableCount = 0;
441459
foreach (var entry in _pendingEntries)

SharpConsoleUI/Controls/DatePickerControl/DatePickerControl.Mouse.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,27 @@ public bool ProcessMouseEvent(MouseEventArgs args)
3535
return true;
3636
}
3737

38-
// Validate within content area
38+
// Validate within actual rendered content area, not full layout bounds
3939
int contentHeight = _lastLayoutBounds.Height > 0 ? _lastLayoutBounds.Height : 1;
4040
if (args.Position.Y < Margin.Top ||
41-
args.Position.Y >= contentHeight - Margin.Bottom ||
42-
args.Position.X < Margin.Left ||
43-
args.Position.X >= _lastLayoutBounds.Width - Margin.Right)
41+
args.Position.Y >= contentHeight - Margin.Bottom)
4442
{
4543
return false;
4644
}
4745

46+
int contentX = args.Position.X - Margin.Left;
47+
if (contentX < 0 || contentX >= _lastContentWidth)
48+
{
49+
// Click is outside the visual content — close calendar if open
50+
if (_isCalendarOpen && args.HasFlag(MouseFlags.Button1Released))
51+
{
52+
CloseCalendar();
53+
Container?.Invalidate(true);
54+
return true;
55+
}
56+
return false;
57+
}
58+
4859
if (args.HasAnyFlag(MouseFlags.ReportMousePosition))
4960
{
5061
MouseMove?.Invoke(this, args);

SharpConsoleUI/Controls/DatePickerControl/DatePickerControl.Rendering.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
141141
buffer.SetNarrowCell(writeX, paintY, ' ', foregroundColor, backgroundColor);
142142
writeX++;
143143

144+
// Cache actual content width for hit-testing
145+
_lastContentWidth = writeX - startX;
146+
144147
// Fill remaining space on the line with window background
145148
int rightFillWidth = bounds.Right - Margin.Right - writeX;
146149
if (rightFillWidth > 0)

SharpConsoleUI/Controls/DatePickerControl/DatePickerControl.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ private enum SegmentType { Month, Day, Year }
5959
private bool _hasFocus;
6060
private bool _isEnabled = true;
6161
private LayoutRect _lastLayoutBounds;
62+
private int _lastContentWidth;
6263

6364
private Color? _backgroundColorValue;
6465
private Color? _foregroundColorValue;

SharpConsoleUI/Controls/DropdownControl/DropdownControl.Mouse.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,25 @@ public bool ProcessMouseEvent(MouseEventArgs args)
4747
return true;
4848
}
4949

50-
// Validate click is within content area (not in margins)
51-
// Margins are non-interactive spacing around the control
50+
// Validate click is within the actual rendered header area, not the full layout bounds.
51+
// The header may be narrower than the layout allocation (e.g. in a row/toolbar).
5252
int contentHeight = (_lastLayoutBounds.Height > 0 ? _lastLayoutBounds.Height : 1);
5353
if (args.Position.Y < Margin.Top ||
54-
args.Position.Y >= contentHeight - Margin.Bottom ||
55-
args.Position.X < Margin.Left ||
56-
args.Position.X >= (_lastLayoutBounds.Width - Margin.Right))
54+
args.Position.Y >= contentHeight - Margin.Bottom)
5755
{
58-
// Click is in margin area - not interactive
56+
return false;
57+
}
58+
59+
int contentX = args.Position.X - Margin.Left;
60+
if (contentX < _lastAlignOffset || contentX >= _lastAlignOffset + _lastHeaderWidth)
61+
{
62+
// Click is outside the visual header content — close dropdown if open, but don't open
63+
if (_isDropdownOpen && args.HasFlag(MouseFlags.Button1Released))
64+
{
65+
IsDropdownOpen = false;
66+
Container?.Invalidate(true);
67+
return true;
68+
}
5969
return false;
6070
}
6171

SharpConsoleUI/Controls/DropdownControl/DropdownControl.Portal.cs

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ private void OpenDropdown()
3535
if (window != null)
3636
{
3737
_portalContent = new DropdownPortalContent(this);
38+
_portalContent.DismissOnOutsideClick = true;
39+
_portalContent.DismissRequested += (s, e) => CloseDropdown();
3840
_dropdownPortal = window.CreatePortal(this, _portalContent);
3941
}
4042

@@ -124,28 +126,14 @@ private void CalculatePortalBounds()
124126
}
125127

126128
/// <summary>
127-
/// Calculates the optimal dropdown width.
129+
/// Calculates the optimal dropdown portal width based on all items.
128130
/// </summary>
129131
private int CalculateDropdownWidth()
130132
{
131133
int targetWidth = _lastLayoutBounds.Width - Margin.Left - Margin.Right;
132134
if (targetWidth <= 0) targetWidth = 20;
133135

134-
int dropdownWidth = Width ?? (HorizontalAlignment == HorizontalAlignment.Stretch ? targetWidth : calculateOptimalWidth(targetWidth));
135-
136-
int maxItemWidth = 0;
137-
foreach (var item in _items)
138-
{
139-
int itemLength = Parsing.MarkupParser.StripLength(item.Text) + 4;
140-
if (itemLength > maxItemWidth) maxItemWidth = itemLength;
141-
}
142-
143-
if (_autoAdjustWidth)
144-
dropdownWidth = Math.Max(dropdownWidth, maxItemWidth + 4);
145-
146-
int promptLength = Parsing.MarkupParser.StripLength(_prompt);
147-
int minWidth = Math.Max(promptLength + 5, maxItemWidth + 4);
148-
dropdownWidth = Math.Min(Math.Max(dropdownWidth, minWidth), targetWidth);
136+
int dropdownWidth = calculatePortalWidth(targetWidth);
149137

150138
return dropdownWidth;
151139
}

SharpConsoleUI/Controls/DropdownControl/DropdownControl.Rendering.cs

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,11 @@ public partial class DropdownControl
1919
/// <inheritdoc/>
2020
public override LayoutSize MeasureDOM(LayoutConstraints constraints)
2121
{
22-
int dropdownWidth = Width ?? (HorizontalAlignment == HorizontalAlignment.Stretch
23-
? constraints.MaxWidth - Margin.Left - Margin.Right
24-
: calculateOptimalWidth(constraints.MaxWidth));
25-
26-
// Ensure width can accommodate content
27-
int maxItemWidth = 0;
28-
List<DropdownItem> measureSnapshot;
29-
lock (_dropdownLock) { measureSnapshot = _items.ToList(); }
30-
foreach (var item in measureSnapshot)
31-
{
32-
int itemLength = Parsing.MarkupParser.StripLength(item.Text) + 4;
33-
if (itemLength > maxItemWidth) maxItemWidth = itemLength;
34-
}
35-
36-
if (_autoAdjustWidth)
37-
dropdownWidth = Math.Max(dropdownWidth, maxItemWidth + 4);
22+
int dropdownWidth = calculateHeaderWidth(constraints.MaxWidth - Margin.Left - Margin.Right);
3823

39-
int promptLength = Parsing.MarkupParser.StripLength(_prompt);
40-
int minWidth = Math.Max(promptLength + 5, maxItemWidth + 4);
24+
// Sane minimum: prompt + arrow + space for at least a few chars
25+
string arrow = "▼";
26+
int minWidth = Parsing.MarkupParser.StripLength($"{_prompt} {arrow}") + 3;
4127
dropdownWidth = Math.Max(dropdownWidth, minWidth);
4228

4329
// Calculate height - constant (header only), dropdown items render via portal
@@ -90,22 +76,18 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
9076
// Fill top margin
9177
ControlRenderingHelpers.FillTopMargin(buffer, bounds, clipRect, startY, foregroundColor, windowBackground, preserveBg);
9278

93-
// Calculate dropdown width
94-
int dropdownWidth = Width ?? (HorizontalAlignment == HorizontalAlignment.Stretch ? targetWidth : calculateOptimalWidth(targetWidth));
95-
int maxItemWidth = 0;
79+
// Calculate dropdown width using header measurement
80+
int dropdownWidth = calculateHeaderWidth(targetWidth);
81+
82+
// Sane minimum: prompt + arrow + space for at least a few chars
83+
string arrowMin = "▼";
84+
int minWidth = Parsing.MarkupParser.StripLength($"{_prompt} {arrowMin}") + 3;
85+
dropdownWidth = Math.Min(Math.Max(dropdownWidth, minWidth), targetWidth);
86+
9687
List<DropdownItem> paintSnapshot;
9788
lock (_dropdownLock) { paintSnapshot = _items.ToList(); }
98-
foreach (var item in paintSnapshot)
99-
{
100-
int itemLength = Parsing.MarkupParser.StripLength(item.Text) + 4;
101-
if (itemLength > maxItemWidth) maxItemWidth = itemLength;
102-
}
103-
if (_autoAdjustWidth)
104-
dropdownWidth = Math.Max(dropdownWidth, maxItemWidth + 4);
10589

10690
int promptLength = Parsing.MarkupParser.StripLength(_prompt);
107-
int minWidth = Math.Max(promptLength + 5, maxItemWidth + 4);
108-
dropdownWidth = Math.Min(Math.Max(dropdownWidth, minWidth), targetWidth);
10991

11092
// Calculate alignment offset
11193
int alignOffset = 0;
@@ -122,22 +104,27 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
122104
}
123105
}
124106

107+
// Cache for hit-testing in ProcessMouseEvent
108+
_lastHeaderWidth = dropdownWidth;
109+
_lastAlignOffset = alignOffset;
110+
125111
int selectedIdx = CurrentSelectedIndex;
126112
int highlightedIdx = CurrentHighlightedIndex;
127113
int dropdownScroll = CurrentDropdownScrollOffset;
128114

129-
// Render header
115+
// Render header: arrow flush-right, padding between text and arrow
130116
string selectedText = selectedIdx >= 0 && selectedIdx < paintSnapshot.Count ? paintSnapshot[selectedIdx].Text : "(None)";
131-
// Arrow shows direction: ▲ when open upward, ▼ when closed or open downward
132117
string arrow = _isDropdownOpen && _opensUpward ? "▲" : "▼";
133-
int maxSelectedTextLength = dropdownWidth - promptLength - 5;
118+
string suffix = $" {arrow}";
119+
int suffixLen = Parsing.MarkupParser.StripLength(suffix);
120+
int maxSelectedTextLength = dropdownWidth - promptLength - 1 - suffixLen; // 1 = space after prompt
134121
if (maxSelectedTextLength > 0 && Parsing.MarkupParser.StripLength(selectedText) > maxSelectedTextLength)
135122
selectedText = TextTruncationHelper.Truncate(selectedText, maxSelectedTextLength);
136123

137-
string headerContent = $"{_prompt} {selectedText} {arrow}";
138-
int headerVisibleLength = Parsing.MarkupParser.StripLength(headerContent);
139-
if (headerVisibleLength < dropdownWidth)
140-
headerContent += new string(' ', dropdownWidth - headerVisibleLength);
124+
string prefix = $"{_prompt} {selectedText}";
125+
int prefixLen = Parsing.MarkupParser.StripLength(prefix);
126+
int paddingNeeded = Math.Max(0, dropdownWidth - prefixLen - suffixLen);
127+
string headerContent = prefix + new string(' ', paddingNeeded) + suffix;
141128

142129
int paintY = startY;
143130

0 commit comments

Comments
 (0)