|
| 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. |
0 commit comments