Skip to content

Commit 7487f6e

Browse files
qcha0sRobert French
andauthored
Added on-close option for multiple windows in desktop app (#66)
* added on-close option for multiple windows in desktop app * missed mix format for PR --------- Co-authored-by: Robert French <rfrench@carmacorp.com>
1 parent 0966857 commit 7487f6e

3 files changed

Lines changed: 418 additions & 188 deletions

File tree

docs/sdl-on-close-option.md

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# SDL: Add `on_close` Option to `Desktop.Window`
2+
3+
**Author:** Robert French
4+
**Date:** 2026-02-23
5+
**Status:** Proposed
6+
**Affects:** `Desktop.Window`
7+
8+
---
9+
10+
## Problem
11+
12+
Applications that use multiple `Desktop.Window` instances have no way to control
13+
what happens when a user clicks the native close button (the red X on macOS, the
14+
X on Windows/Linux) on a secondary window.
15+
16+
The current `handle_cast(:close_window, ...)` implementation decides the close
17+
behavior based on the presence of a `taskbar` (which is derived from the
18+
`icon_menu` option):
19+
20+
```elixir
21+
if taskbar == nil do
22+
OS.shutdown() # terminates the entire application
23+
else
24+
:wxFrame.hide(frame)
25+
end
26+
```
27+
28+
This means the **only** way to get hide-on-close behavior is to provide an
29+
`icon_menu`, which creates a system tray icon. For secondary windows like
30+
Settings or Preferences panels, this is inappropriate — they should be
31+
dismissible without either terminating the application or requiring a taskbar
32+
icon.
33+
34+
### Impact
35+
36+
On macOS, `OS.shutdown()` during wxWidgets teardown of a secondary window causes
37+
a segmentation fault (SIGSEGV, exit code 139). On other platforms it causes an
38+
unexpected full application exit when the user only intended to close a secondary
39+
window.
40+
41+
---
42+
43+
## Solution
44+
45+
Add an explicit `on_close` configuration option to `Desktop.Window` that
46+
decouples the close behavior from the taskbar presence.
47+
48+
### New Option
49+
50+
| Option | Values | Default | Description |
51+
|---|---|---|---|
52+
| `:on_close` | `:quit`, `:hide` | `:quit` | Controls behavior when the native close button is clicked |
53+
54+
- **`:quit`** — Existing behavior. Shuts down the application (via
55+
`OS.shutdown()`). Appropriate for primary/main windows.
56+
- **`:hide`** — Hides the window frame. Appropriate for secondary windows
57+
(settings, preferences, tool panels) that should be dismissible without
58+
terminating the application.
59+
60+
### Usage
61+
62+
```elixir
63+
children = [
64+
# Primary window — closes the app (default behavior, unchanged)
65+
{Desktop.Window,
66+
[
67+
app: :my_app,
68+
id: MainWindow,
69+
title: "My App",
70+
icon_menu: MyApp.IconMenu,
71+
url: &MyAppWeb.Endpoint.url/0
72+
]},
73+
74+
# Secondary window — hides on close
75+
{Desktop.Window,
76+
[
77+
app: :my_app,
78+
id: SettingsWindow,
79+
title: "Settings",
80+
hidden: true,
81+
on_close: :hide,
82+
url: fn -> MyAppWeb.Endpoint.url() <> "/settings" end
83+
]}
84+
]
85+
```
86+
87+
---
88+
89+
## Changes
90+
91+
### 1. `%Desktop.Window{}` struct (`lib/desktop/window.ex`)
92+
93+
Add `on_close` field with a default of `:quit` for full backwards compatibility:
94+
95+
```elixir
96+
defstruct [
97+
:module,
98+
:taskbar,
99+
:frame,
100+
:notifications,
101+
:webview,
102+
:home_url,
103+
:last_url,
104+
:title,
105+
on_close: :quit
106+
]
107+
```
108+
109+
### 2. `init/1` (`lib/desktop/window.ex`)
110+
111+
Read the option from the configuration keywords and store it in the struct:
112+
113+
```elixir
114+
on_close = options[:on_close] || :quit
115+
116+
ui = %Window{
117+
frame: frame,
118+
webview: Fallback.webview_new(frame),
119+
notifications: %{},
120+
home_url: url,
121+
title: window_title,
122+
taskbar: taskbar,
123+
on_close: on_close
124+
}
125+
```
126+
127+
### 3. `handle_cast(:close_window, ...)` (`lib/desktop/window.ex`)
128+
129+
Check `on_close` before falling through to the existing logic:
130+
131+
```elixir
132+
def handle_cast(:close_window, ui = %Window{frame: frame, taskbar: taskbar, on_close: on_close}) do
133+
if on_close == :hide do
134+
:wxFrame.hide(frame)
135+
{:noreply, ui}
136+
else
137+
# Existing behavior preserved exactly as-is
138+
if not :wxFrame.isShown(frame) do
139+
OS.shutdown()
140+
end
141+
142+
if taskbar == nil do
143+
OS.shutdown()
144+
{:noreply, ui}
145+
else
146+
:wxFrame.hide(frame)
147+
{:noreply, ui}
148+
end
149+
end
150+
end
151+
```
152+
153+
### 4. `@moduledoc` (`lib/desktop/window.ex`)
154+
155+
Document the new option alongside the existing configuration options:
156+
157+
```
158+
* `:on_close` - controls the behavior when the native window close
159+
button is clicked.
160+
161+
Possible values are:
162+
163+
* `:quit` - Shut down the application (default). This is the
164+
legacy behavior and is appropriate for main/primary windows.
165+
166+
* `:hide` - Hide the window instead of quitting. Useful for
167+
secondary windows (e.g. settings, preferences) that should
168+
be dismissible without terminating the application.
169+
```
170+
171+
### 5. `desktop.install` mix task (`lib/mix/tasks/desktop.install.ex`)
172+
173+
Guard the module definition with `Code.ensure_loaded?/1` so the optional
174+
`igniter` dependency doesn't cause compilation failures when Desktop is used as
175+
a path dependency:
176+
177+
```elixir
178+
if Code.ensure_loaded?(Igniter.Mix.Task) do
179+
defmodule Mix.Tasks.Desktop.Install do
180+
# ...
181+
end
182+
end
183+
```
184+
185+
---
186+
187+
## Backwards Compatibility
188+
189+
- The default value of `on_close` is `:quit`, which preserves the exact
190+
existing behavior for all current users.
191+
- No existing configuration options are modified or removed.
192+
- The `taskbar`-based logic is unchanged when `on_close` is `:quit`.
193+
- Applications that do not pass `on_close` will behave identically to
194+
previous versions.
195+
196+
---
197+
198+
## Testing
199+
200+
Manual verification:
201+
202+
1. **Primary window with `on_close: :quit` (default):** Clicking the close
203+
button shuts down the application — unchanged behavior.
204+
2. **Secondary window with `on_close: :hide`:** Clicking the close button
205+
hides the window. The application continues running. The window can be
206+
re-shown via `Desktop.Window.show/2`.
207+
3. **macOS Cmd+Q:** Application quits normally regardless of `on_close`
208+
setting on any window.

lib/desktop/window.ex

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ defmodule Desktop.Window do
7373
* `:url` - a callback to the initial (default) url to show in the
7474
window.
7575
76+
* `:on_close` - controls the behavior when the native window close
77+
button is clicked.
78+
79+
Possible values are:
80+
81+
* `:quit` - Shut down the application (default). This is the
82+
legacy behavior and is appropriate for main/primary windows.
83+
84+
* `:hide` - Hide the window instead of quitting. Useful for
85+
secondary windows (e.g. settings, preferences) that should
86+
be dismissible without terminating the application.
87+
7688
"""
7789

7890
alias Desktop.{OS, Window, Wx, Menu, Fallback}
@@ -87,7 +99,8 @@ defmodule Desktop.Window do
8799
:webview,
88100
:home_url,
89101
:last_url,
90-
:title
102+
:title,
103+
on_close: :quit
91104
]
92105

93106
@doc false
@@ -121,6 +134,7 @@ defmodule Desktop.Window do
121134
icon_menu = unless OS.mobile?(), do: options[:icon_menu]
122135
hidden = unless OS.mobile?(), do: options[:hidden]
123136
url = options[:url]
137+
on_close = options[:on_close] || :quit
124138

125139
Desktop.Env.wx_use_env()
126140
GenServer.cast(Desktop.Env, {:register_window, self()})
@@ -209,7 +223,8 @@ defmodule Desktop.Window do
209223
notifications: %{},
210224
home_url: url,
211225
title: window_title,
212-
taskbar: taskbar
226+
taskbar: taskbar,
227+
on_close: on_close
213228
}
214229

215230
if hidden != true do
@@ -565,26 +580,31 @@ defmodule Desktop.Window do
565580
end
566581

567582
@doc false
568-
def handle_cast(:close_window, ui = %Window{frame: frame, taskbar: taskbar}) do
569-
# On macOS, there's no way to differentiate between following two events:
570-
#
571-
# * the window close event
572-
# * the application close event
573-
#
574-
# So, this code assumes that if there's a closet_window event coming in while
575-
# the window in not actually shown, then it must be an application close event.
576-
#
577-
# On other platforms, this code should not have any relevance.
578-
if not :wxFrame.isShown(frame) do
579-
OS.shutdown()
580-
end
581-
582-
if taskbar == nil do
583-
OS.shutdown()
584-
{:noreply, ui}
585-
else
583+
def handle_cast(:close_window, ui = %Window{frame: frame, taskbar: taskbar, on_close: on_close}) do
584+
if on_close == :hide do
586585
:wxFrame.hide(frame)
587586
{:noreply, ui}
587+
else
588+
# On macOS, there's no way to differentiate between following two events:
589+
#
590+
# * the window close event
591+
# * the application close event
592+
#
593+
# So, this code assumes that if there's a close_window event coming in while
594+
# the window is not actually shown, then it must be an application close event.
595+
#
596+
# On other platforms, this code should not have any relevance.
597+
if not :wxFrame.isShown(frame) do
598+
OS.shutdown()
599+
end
600+
601+
if taskbar == nil do
602+
OS.shutdown()
603+
{:noreply, ui}
604+
else
605+
:wxFrame.hide(frame)
606+
{:noreply, ui}
607+
end
588608
end
589609
end
590610

0 commit comments

Comments
 (0)