Skip to content

Commit 55ded28

Browse files
committed
Add dismiss-on-outside-click for portals and fix right-click cursor in MultilineEdit
Portal system: add opt-in DismissOnOutsideClick property to IHasPortalBounds and PortalContentBase. Portals with this enabled are automatically dismissed on outside click and window deactivation, with a DismissRequested event for cleanup. Defaults to false so existing portals are unaffected. MultilineEdit: move cursor to click position before firing MouseRightClick, matching standard editor behavior.
1 parent f7a97b2 commit 55ded28

6 files changed

Lines changed: 164 additions & 2 deletions

File tree

SharpConsoleUI/Controls/MultilineEdit/MultilineEditControl.Mouse.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,16 @@ public bool ProcessMouseEvent(MouseEventArgs args)
200200
return true;
201201
}
202202

203-
// Handle right-click
203+
// Handle right-click: move cursor to click position first, then fire event
204204
if (args.HasFlag(MouseFlags.Button3Clicked))
205205
{
206+
if (_hasFocus)
207+
{
208+
PositionCursorFromMouseCore(args.Position.X, args.Position.Y);
209+
ClearSelection();
210+
EnsureCursorVisible();
211+
Container?.Invalidate(true);
212+
}
206213
MouseRightClick?.Invoke(this, args);
207214
return true;
208215
}

SharpConsoleUI/Controls/PortalContentBase.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ public abstract class PortalContentBase : IWindowControl, IDOMPaintable, IMouseA
3535
/// </summary>
3636
public abstract Rectangle GetPortalBounds();
3737

38+
/// <inheritdoc/>
39+
public bool DismissOnOutsideClick { get; set; }
40+
41+
/// <summary>
42+
/// Raised when the portal is about to be dismissed due to an outside click.
43+
/// Consumers can use this to perform cleanup before the portal is removed.
44+
/// </summary>
45+
public event EventHandler? DismissRequested;
46+
47+
/// <summary>
48+
/// Raises the <see cref="DismissRequested"/> event.
49+
/// </summary>
50+
internal void RaiseDismissRequested() => DismissRequested?.Invoke(this, EventArgs.Empty);
51+
3852
#endregion
3953

4054
#region IMouseAwareControl Implementation

SharpConsoleUI/Layout/IHasPortalBounds.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@ public interface IHasPortalBounds
1414
/// where this portal overlay should be rendered.
1515
/// </summary>
1616
Rectangle GetPortalBounds();
17+
18+
/// <summary>
19+
/// When true, the portal is automatically dismissed when the user clicks outside its bounds.
20+
/// </summary>
21+
bool DismissOnOutsideClick => false;
1722
}
1823
}

SharpConsoleUI/Window.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2053,6 +2053,38 @@ public void RemovePortal(IWindowControl ownerControl, LayoutNode portalNode)
20532053
Invalidate(false);
20542054
}
20552055

2056+
/// <summary>
2057+
/// Dismisses all portals that have DismissOnOutsideClick enabled.
2058+
/// Called on window deactivation and can be called programmatically.
2059+
/// </summary>
2060+
internal void DismissAutoClosePortals()
2061+
{
2062+
var root = RootLayoutNode;
2063+
if (root == null) return;
2064+
2065+
var toDismiss = new List<LayoutNode>();
2066+
2067+
root.Visit(node =>
2068+
{
2069+
foreach (var portal in node.PortalChildren)
2070+
{
2071+
if (portal.Control is Layout.IHasPortalBounds hasPortalBounds
2072+
&& hasPortalBounds.DismissOnOutsideClick)
2073+
{
2074+
toDismiss.Add(portal);
2075+
}
2076+
}
2077+
});
2078+
2079+
foreach (var portal in toDismiss)
2080+
{
2081+
if (portal.Control is Controls.PortalContentBase portalContent)
2082+
portalContent.RaiseDismissRequested();
2083+
if (portal.Control != null)
2084+
RemovePortal(portal.Control, portal);
2085+
}
2086+
}
2087+
20562088
/// <summary>
20572089
/// Gets the root layout node for this window.
20582090
/// </summary>
@@ -2200,6 +2232,7 @@ public void SetIsActive(bool value)
22002232
}
22012233
else
22022234
{
2235+
DismissAutoClosePortals();
22032236
Deactivated?.Invoke(this, EventArgs.Empty);
22042237
}
22052238

SharpConsoleUI/Windows/WindowEventDispatcher.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ public bool ProcessMouseEvent(Events.MouseEventArgs args)
214214
}
215215
else
216216
{
217+
// === DISMISS PORTALS ON OUTSIDE CLICK ===
218+
DismissOutsideClickPortals(args);
219+
217220
// === EXISTING: NON-SCROLL EVENTS (clicks, etc.) ===
218221
// Centralized focus handling on click (left-click and right-click)
219222
if (args.HasAnyFlag(MouseFlags.Button1Pressed, MouseFlags.Button1Clicked,
@@ -282,6 +285,50 @@ public bool ProcessMouseEvent(Events.MouseEventArgs args)
282285
}
283286
}
284287

288+
/// <summary>
289+
/// Dismisses portals that have DismissOnOutsideClick enabled when a click lands outside their bounds.
290+
/// Collects targets first to avoid modifying collections during iteration.
291+
/// </summary>
292+
private void DismissOutsideClickPortals(MouseEventArgs args)
293+
{
294+
var root = _window.RootLayoutNode;
295+
if (root == null) return;
296+
297+
if (!args.HasAnyFlag(MouseFlags.Button1Pressed, MouseFlags.Button1Clicked,
298+
MouseFlags.Button3Pressed, MouseFlags.Button3Clicked))
299+
return;
300+
301+
var contentPos = GetContentCoordinates(args.WindowPosition);
302+
var toDismiss = new List<LayoutNode>();
303+
304+
root.Visit(node =>
305+
{
306+
foreach (var portal in node.PortalChildren)
307+
{
308+
if (portal.Control is IHasPortalBounds hasPortalBounds
309+
&& hasPortalBounds.DismissOnOutsideClick)
310+
{
311+
var bounds = hasPortalBounds.GetPortalBounds();
312+
if (!bounds.Contains(contentPos))
313+
{
314+
toDismiss.Add(portal);
315+
}
316+
}
317+
}
318+
});
319+
320+
foreach (var portal in toDismiss)
321+
{
322+
if (portal.Control is PortalContentBase portalContent)
323+
portalContent.RaiseDismissRequested();
324+
325+
// Window.RemovePortal always removes from root node regardless of ownerControl,
326+
// so we pass the portal's own control as a placeholder.
327+
if (portal.Control != null)
328+
_window.RemovePortal(portal.Control, portal);
329+
}
330+
}
331+
285332
/// <summary>
286333
/// Checks if a window-relative position is within the content area (not title bar or borders)
287334
/// </summary>

docs/PORTAL_SYSTEM.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ SharpConsoleUI's portal system enables overlay rendering — content that floats
1414
- [Building a Custom Portal](#building-a-custom-portal)
1515
- [Mouse and Keyboard Routing](#mouse-and-keyboard-routing)
1616
- [Nested Containers](#nested-containers)
17+
- [Dismiss on Outside Click](#dismiss-on-outside-click)
1718
- [Lifecycle Management](#lifecycle-management)
1819
- [Quick Reference](#quick-reference)
1920

@@ -400,6 +401,61 @@ This works because:
400401
- **Mouse**: Portal forwards to child, child does its own internal hit-testing for nested children
401402
- **Focus**: `IFocusTrackingContainer` ensures focus tracking through nesting
402403

404+
## Dismiss on Outside Click
405+
406+
Portals can opt in to automatic dismissal when the user clicks outside their bounds. This is useful for dropdowns, context menus, and other transient overlays.
407+
408+
### Enabling
409+
410+
Set `DismissOnOutsideClick` on the portal content:
411+
412+
```csharp
413+
// Via PortalContentBase subclass
414+
var portal = new PortalContentContainer();
415+
portal.DismissOnOutsideClick = true;
416+
417+
// Or via IHasPortalBounds (default-implemented as false)
418+
public bool DismissOnOutsideClick => true;
419+
```
420+
421+
### DismissRequested Event
422+
423+
`PortalContentBase` exposes a `DismissRequested` event that fires **before** the portal is removed. Use it for cleanup:
424+
425+
```csharp
426+
portal.DismissRequested += (sender, e) =>
427+
{
428+
// Clean up state, close related UI, etc.
429+
_portalNode = null;
430+
_portal = null;
431+
};
432+
portal.DismissOnOutsideClick = true;
433+
434+
_portalNode = window.CreatePortal(this, portal);
435+
```
436+
437+
### How It Works
438+
439+
Portals with `DismissOnOutsideClick = true` are dismissed in two scenarios:
440+
441+
**Outside click:**
442+
1. On left-click or right-click, `WindowEventDispatcher` walks all portal nodes
443+
2. For each matching portal, it checks if the click is outside `GetPortalBounds()`
444+
3. Matching portals are collected first (to avoid modifying the tree during iteration)
445+
4. `DismissRequested` fires on each, then `RemovePortal()` removes it
446+
5. Normal click processing continues — the click is **not** consumed
447+
448+
**Window deactivation:**
449+
1. When the window loses focus (another window is activated, or the user clicks the desktop)
450+
2. All portals with `DismissOnOutsideClick = true` are dismissed immediately
451+
3. `DismissRequested` fires before removal, same as outside-click dismissal
452+
453+
### Defaults
454+
455+
- `IHasPortalBounds.DismissOnOutsideClick` defaults to `false` (default interface implementation)
456+
- `PortalContentBase.DismissOnOutsideClick` is a settable property, also defaulting to `false`
457+
- Existing portals are completely unaffected unless they explicitly opt in
458+
403459
## Lifecycle Management
404460

405461
1. **Create** portal content and set bounds
@@ -418,7 +474,7 @@ Always remove portals before disposing the owner control. Portals that outlive t
418474
| `Window.RemovePortal()` | `Window.cs` | Remove a portal overlay |
419475
| `PortalContentBase` | `Controls/PortalContentBase.cs` | Abstract base for portal content |
420476
| `PortalContentContainer` | `Controls/PortalContentContainer.cs` | Container for child controls in portals |
421-
| `IHasPortalBounds` | `Controls/IHasPortalBounds.cs` | Interface for portal bounds |
477+
| `IHasPortalBounds` | `Layout/IHasPortalBounds.cs` | Interface for portal bounds and dismiss opt-in |
422478
| `PortalPositioner` | `Layout/PortalPositioner.cs` | Smart positioning with flip/clamp |
423479
| `PortalPositionRequest` | `Layout/PortalPositioner.cs` | Positioning request record |
424480
| `PortalPositionResult` | `Layout/PortalPositioner.cs` | Positioning result record |

0 commit comments

Comments
 (0)