fix(ui): harden JS dialog popup lifecycle, focus and sizing
Answer an open dialog when its PaneView is cleaned up, only take focus in the active pane (and restore it only if the dialog had it), let focused buttons handle Enter themselves, truncate the origin from the left, cap page text, clip and size the overlay with the modal helpers, and disconnect handlers on Destroy. Resolve panes via the app resolver only.
- commit
- 1982d1a1ca8fcf5fe939b5ae42e27740567c637a
- author
- brice · · 2026-10-03 07:02 UTC
- committer
- brice · · 2026-10-03 07:50 UTC
- parent
- 26ad11f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +275 -114
M internal/ui/app.go +5 -7
| @@ -3426,18 +3426,16 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3426 | 3426 | ||
| 3427 | 3427 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | |
| 3428 | 3428 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | |
| 3429 | + | a.contentCoord.SetUIScaleProvider(func() float64 { | |
| 3430 | + | return a.runtimeConfigSnapshot().UI.DefaultUIScale | |
| 3431 | + | }) | |
| 3429 | 3432 | ||
| 3430 | 3433 | // Hide loading skeleton once the WebView paints | |
| 3431 | 3434 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | |
| 3432 | 3435 | // The WebView can be shown while its pane is in a background tab, so scan | |
| 3433 | 3436 | // all WorkspaceViews rather than only the active one. | |
| 3434 | - | for _, wsView := range a.workspaceViews { | |
| 3435 | - | if wsView == nil { | |
| 3436 | - | continue | |
| 3437 | - | } | |
| 3438 | - | if pv := wsView.GetPaneView(paneID); pv != nil { | |
| 3439 | - | pv.HideLoadingSkeleton() | |
| 3440 | - | } | |
| 3437 | + | if pv := a.paneViewForPane(paneID); pv != nil { | |
| 3438 | + | pv.HideLoadingSkeleton() | |
| 3441 | 3439 | } | |
| 3442 | 3440 | if a.deps != nil && a.deps.OnFirstWebViewShown != nil { | |
| 3443 | 3441 | a.firstWebViewShownOnce.Do(func() { | |
| @@ -3426,18 +3426,16 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3426 | 3426 | ||||
| 3427 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | 3427 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | ||
| 3428 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | 3428 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | ||
| 3429 | + | a.contentCoord.SetUIScaleProvider(func() float64 { | |||
| 3430 | + | return a.runtimeConfigSnapshot().UI.DefaultUIScale | |||
| 3431 | + | }) | |||
| 3429 | 3432 | ||||
| 3430 | // Hide loading skeleton once the WebView paints | 3433 | // Hide loading skeleton once the WebView paints | ||
| 3431 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | 3434 | a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) { | ||
| 3432 | // The WebView can be shown while its pane is in a background tab, so scan | 3435 | // The WebView can be shown while its pane is in a background tab, so scan | ||
| 3433 | // all WorkspaceViews rather than only the active one. | 3436 | // all WorkspaceViews rather than only the active one. | ||
| 3434 | - | for _, wsView := range a.workspaceViews { | 3437 | + | if pv := a.paneViewForPane(paneID); pv != nil { |
| 3435 | - | if wsView == nil { | 3438 | + | pv.HideLoadingSkeleton() |
| 3436 | - | continue | |||
| 3437 | - | } | |||
| 3438 | - | if pv := wsView.GetPaneView(paneID); pv != nil { | |||
| 3439 | - | pv.HideLoadingSkeleton() | |||
| 3440 | - | } | |||
| 3441 | } | 3439 | } | ||
| 3442 | if a.deps != nil && a.deps.OnFirstWebViewShown != nil { | 3440 | if a.deps != nil && a.deps.OnFirstWebViewShown != nil { | ||
| 3443 | a.firstWebViewShownOnce.Do(func() { | 3441 | a.firstWebViewShownOnce.Do(func() { | ||
M internal/ui/component/jsdialog_popup.go +166 -62
| @@ -10,15 +10,30 @@ import ( | |||
| 10 | 10 | "github.com/bnema/puregotk/v4/pango" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | const ( | |
| 16 | 17 | jsDialogMaxBodyHeight = 240 | |
| 17 | 18 | jsDialogMaxWidthChars = 60 | |
| 18 | 19 | jsDialogMaxHostChars = 80 | |
| 19 | - | jsDialogDialogWidth = 420 | |
| 20 | + | // jsDialogMaxTextRunes caps page-controlled text (message, prompt default) | |
| 21 | + | // so a hostile page can't make GTK lay out megabytes of text. | |
| 22 | + | jsDialogMaxTextRunes = 3000 | |
| 23 | + | // jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always | |
| 24 | + | // passes a fixed string, and browsers no longer show page text here. | |
| 25 | + | jsDialogBeforeUnloadText = "Changes you made may not be saved." | |
| 20 | 26 | ) | |
| 21 | 27 | ||
| 28 | + | // JSDialogSizeDefaults sizes the dialog box relative to its pane. | |
| 29 | + | var JSDialogSizeDefaults = ModalSizeConfig{ | |
| 30 | + | WidthPct: 0.9, | |
| 31 | + | MaxWidth: 420, | |
| 32 | + | TopMarginPct: 0.3, | |
| 33 | + | FallbackWidth: 420, | |
| 34 | + | FallbackHeight: 300, | |
| 35 | + | } | |
| 36 | + | ||
| 22 | 37 | // jsDialogContent is the plain-text content of a JS dialog. | |
| 23 | 38 | type jsDialogContent struct { | |
| 24 | 39 | Heading string | |
| @@ -39,11 +54,21 @@ func jsDialogOriginLabel(origin string) string { | |||
| 39 | 54 | u, err := url.Parse(origin) | |
| 40 | 55 | if err != nil || u.Host == "" { | |
| 41 | 56 | if err == nil && u.Scheme != "" { | |
| 42 | - | return truncateRunes(u.Scheme+":", jsDialogMaxHostChars) | |
| 57 | + | return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars) | |
| 43 | 58 | } | |
| 44 | 59 | return "This page" | |
| 45 | 60 | } | |
| 46 | - | return truncateRunes(u.Host, jsDialogMaxHostChars) | |
| 61 | + | // Keep the end of the host: the registrable domain is what identifies it. | |
| 62 | + | return truncateRunesLeft(u.Host, jsDialogMaxHostChars) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // truncateRunesLeft keeps the last n runes, prefixing "…" when it cut. | |
| 66 | + | func truncateRunesLeft(s string, n int) string { | |
| 67 | + | r := []rune(s) | |
| 68 | + | if len(r) <= n { | |
| 69 | + | return s | |
| 70 | + | } | |
| 71 | + | return "…" + string(r[len(r)-n:]) | |
| 47 | 72 | } | |
| 48 | 73 | ||
| 49 | 74 | func truncateRunes(s string, n int) string { | |
| @@ -58,16 +83,14 @@ func truncateRunes(s string, n int) string { | |||
| 58 | 83 | // All strings are plain text (never Pango markup). | |
| 59 | 84 | func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent { | |
| 60 | 85 | says := jsDialogOriginLabel(req.Origin) + " says:" | |
| 86 | + | msg := truncateRunes(req.Message, jsDialogMaxTextRunes) | |
| 61 | 87 | switch req.Type { | |
| 62 | 88 | case port.JSDialogConfirm: | |
| 63 | - | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"} | |
| 89 | + | return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"} | |
| 64 | 90 | case port.JSDialogPrompt: | |
| 65 | - | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true} | |
| 91 | + | return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true} | |
| 66 | 92 | case port.JSDialogBeforeUnload: | |
| 67 | - | body := strings.TrimSpace(req.Message) | |
| 68 | - | if body == "" { | |
| 69 | - | body = "Changes you made may not be saved." | |
| 70 | - | } | |
| 93 | + | body := jsDialogBeforeUnloadText | |
| 71 | 94 | heading := "Leave page?" | |
| 72 | 95 | if req.IsReload { | |
| 73 | 96 | heading = "Reload page?" | |
| @@ -79,7 +102,7 @@ func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent { | |||
| 79 | 102 | CancelLabel: "Stay", | |
| 80 | 103 | } | |
| 81 | 104 | default: | |
| 82 | - | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"} | |
| 105 | + | return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"} | |
| 83 | 106 | } | |
| 84 | 107 | } | |
| 85 | 108 | ||
| @@ -97,17 +120,30 @@ type JSDialogPopup struct { | |||
| 97 | 120 | btnOK *gtk.Button | |
| 98 | 121 | btnCancel *gtk.Button | |
| 99 | 122 | ||
| 100 | - | mu sync.Mutex | |
| 101 | - | visible bool | |
| 102 | - | respond func(ok bool, input string) | |
| 103 | - | onHidden func() | |
| 123 | + | parent layout.OverlayWidget | |
| 124 | + | controller *gtk.EventControllerKey | |
| 125 | + | signals []popupSignal | |
| 126 | + | ||
| 127 | + | mu sync.Mutex | |
| 128 | + | visible bool | |
| 129 | + | wantFocus bool // this dialog should own keyboard focus (pane is active) | |
| 130 | + | destroyed bool | |
| 131 | + | respond func(ok bool, input string) | |
| 132 | + | onHidden func(hadFocus bool) | |
| 104 | 133 | ||
| 105 | 134 | retainedCallbacks []any | |
| 106 | 135 | } | |
| 107 | 136 | ||
| 137 | + | // popupSignal remembers a connected handler so Destroy can disconnect it. | |
| 138 | + | type popupSignal struct { | |
| 139 | + | obj interface{ DisconnectSignal(uint) } | |
| 140 | + | id uint | |
| 141 | + | } | |
| 142 | + | ||
| 108 | 143 | // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails. | |
| 109 | - | func NewJSDialogPopup() *JSDialogPopup { | |
| 110 | - | p := &JSDialogPopup{} | |
| 144 | + | // parent is the pane overlay used to size the dialog. | |
| 145 | + | func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup { | |
| 146 | + | p := &JSDialogPopup{parent: parent} | |
| 111 | 147 | if err := p.createWidgets(); err != nil { | |
| 112 | 148 | return nil | |
| 113 | 149 | } | |
| @@ -123,25 +159,40 @@ func (p *JSDialogPopup) Widget() *gtk.Widget { | |||
| 123 | 159 | return &p.outerBox.Widget | |
| 124 | 160 | } | |
| 125 | 161 | ||
| 126 | - | // SetOnHidden sets a callback run after the popup closes (e.g. to restore focus). | |
| 127 | - | func (p *JSDialogPopup) SetOnHidden(fn func()) { | |
| 162 | + | // SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports | |
| 163 | + | // whether keyboard focus was inside the dialog when it closed, so the owner | |
| 164 | + | // only restores focus when the dialog had taken it. | |
| 165 | + | func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) { | |
| 128 | 166 | p.mu.Lock() | |
| 129 | 167 | p.onHidden = fn | |
| 130 | 168 | p.mu.Unlock() | |
| 131 | 169 | } | |
| 132 | 170 | ||
| 133 | 171 | // Show displays the dialog. respond is invoked once with the user's answer. | |
| 134 | - | // A dialog already visible is dismissed as canceled first. | |
| 135 | - | func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string)) { | |
| 136 | - | p.dismiss(false) | |
| 172 | + | // A dialog already visible is dismissed as canceled first. Keyboard focus is | |
| 173 | + | // only taken when focus is true (the pane is active); otherwise RequestFocus | |
| 174 | + | // grabs it when the pane becomes active. | |
| 175 | + | func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) { | |
| 176 | + | p.Dismiss(false) | |
| 137 | 177 | ||
| 138 | 178 | content := buildJSDialogContent(req) | |
| 139 | 179 | ||
| 140 | 180 | p.mu.Lock() | |
| 181 | + | if p.destroyed { | |
| 182 | + | p.mu.Unlock() | |
| 183 | + | if respond != nil { | |
| 184 | + | respond(false, "") | |
| 185 | + | } | |
| 186 | + | return | |
| 187 | + | } | |
| 141 | 188 | p.visible = true | |
| 189 | + | p.wantFocus = focus | |
| 142 | 190 | p.respond = respond | |
| 143 | 191 | p.mu.Unlock() | |
| 144 | 192 | ||
| 193 | + | width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale) | |
| 194 | + | p.mainBox.SetSizeRequest(width, -1) | |
| 195 | + | ||
| 145 | 196 | p.headingLabel.SetText(content.Heading) | |
| 146 | 197 | p.bodyLabel.SetText(content.Body) | |
| 147 | 198 | p.bodyLabel.SetVisible(content.Body != "") | |
| @@ -152,18 +203,38 @@ func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, inp | |||
| 152 | 203 | } | |
| 153 | 204 | p.input.SetVisible(content.ShowInput) | |
| 154 | 205 | if content.ShowInput { | |
| 155 | - | p.input.SetText(req.DefaultPrompt) | |
| 206 | + | p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes)) | |
| 156 | 207 | } else { | |
| 157 | 208 | p.input.SetText("") | |
| 158 | 209 | } | |
| 159 | 210 | ||
| 160 | 211 | p.outerBox.SetVisible(true) | |
| 161 | - | if content.ShowInput { | |
| 212 | + | if focus { | |
| 213 | + | p.grabDefaultFocus() | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // RequestFocus marks the dialog as the focus owner and grabs focus if shown. | |
| 218 | + | // Called when the owning pane becomes active. | |
| 219 | + | func (p *JSDialogPopup) RequestFocus() { | |
| 220 | + | p.mu.Lock() | |
| 221 | + | if !p.visible || p.destroyed { | |
| 222 | + | p.mu.Unlock() | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | p.wantFocus = true | |
| 226 | + | p.mu.Unlock() | |
| 227 | + | p.grabDefaultFocus() | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // grabDefaultFocus focuses the entry (prompt) or the OK button. | |
| 231 | + | func (p *JSDialogPopup) grabDefaultFocus() { | |
| 232 | + | if p.input.GetVisible() { | |
| 162 | 233 | p.input.GrabFocus() | |
| 163 | 234 | p.input.SelectRegion(0, -1) | |
| 164 | - | } else { | |
| 165 | - | p.btnOK.GrabFocus() | |
| 235 | + | return | |
| 166 | 236 | } | |
| 237 | + | p.btnOK.GrabFocus() | |
| 167 | 238 | } | |
| 168 | 239 | ||
| 169 | 240 | // Hide closes the popup without invoking the response callback. It is used when | |
| @@ -172,52 +243,68 @@ func (p *JSDialogPopup) Hide() { | |||
| 172 | 243 | if p == nil { | |
| 173 | 244 | return | |
| 174 | 245 | } | |
| 175 | - | p.mu.Lock() | |
| 176 | - | wasVisible := p.visible | |
| 177 | - | p.visible = false | |
| 178 | - | p.respond = nil | |
| 179 | - | hidden := p.onHidden | |
| 180 | - | p.mu.Unlock() | |
| 181 | - | if !wasVisible { | |
| 182 | - | return | |
| 183 | - | } | |
| 184 | - | p.outerBox.SetVisible(false) | |
| 185 | - | if hidden != nil { | |
| 186 | - | hidden() | |
| 187 | - | } | |
| 246 | + | p.close(nil) | |
| 188 | 247 | } | |
| 189 | 248 | ||
| 190 | - | // IsVisible reports whether a dialog is displayed. | |
| 191 | - | func (p *JSDialogPopup) IsVisible() bool { | |
| 192 | - | p.mu.Lock() | |
| 193 | - | defer p.mu.Unlock() | |
| 194 | - | return p.visible | |
| 249 | + | // Dismiss closes the popup and answers with ok. No-op when hidden. | |
| 250 | + | func (p *JSDialogPopup) Dismiss(ok bool) { | |
| 251 | + | p.close(&ok) | |
| 195 | 252 | } | |
| 196 | 253 | ||
| 197 | - | // dismiss closes the popup and answers. Safe to call when hidden (no-op). | |
| 198 | - | func (p *JSDialogPopup) dismiss(ok bool) { | |
| 254 | + | // close hides the popup. When answer is non-nil the response callback is run. | |
| 255 | + | func (p *JSDialogPopup) close(answer *bool) { | |
| 199 | 256 | p.mu.Lock() | |
| 200 | 257 | if !p.visible { | |
| 201 | 258 | p.mu.Unlock() | |
| 202 | 259 | return | |
| 203 | 260 | } | |
| 204 | 261 | p.visible = false | |
| 262 | + | p.wantFocus = false | |
| 205 | 263 | respond := p.respond | |
| 206 | 264 | p.respond = nil | |
| 207 | 265 | hidden := p.onHidden | |
| 208 | 266 | p.mu.Unlock() | |
| 209 | 267 | ||
| 268 | + | hadFocus := p.outerBox.GetFocusChild() != nil | |
| 210 | 269 | text := "" | |
| 211 | - | if ok && p.input.GetVisible() { | |
| 270 | + | if answer != nil && *answer && p.input.GetVisible() { | |
| 212 | 271 | text = p.input.GetText() | |
| 213 | 272 | } | |
| 214 | 273 | p.outerBox.SetVisible(false) | |
| 215 | - | if respond != nil { | |
| 216 | - | respond(ok, text) | |
| 274 | + | if answer != nil && respond != nil { | |
| 275 | + | respond(*answer, text) | |
| 217 | 276 | } | |
| 218 | 277 | if hidden != nil { | |
| 219 | - | hidden() | |
| 278 | + | hidden(hadFocus) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // Destroy disconnects every handler and drops callbacks. The popup must not be | |
| 283 | + | // used afterwards. Call after the dialog was dismissed. | |
| 284 | + | func (p *JSDialogPopup) Destroy() { | |
| 285 | + | if p == nil { | |
| 286 | + | return | |
| 220 | 287 | } | |
| 288 | + | p.mu.Lock() | |
| 289 | + | if p.destroyed { | |
| 290 | + | p.mu.Unlock() | |
| 291 | + | return | |
| 292 | + | } | |
| 293 | + | p.destroyed = true | |
| 294 | + | p.visible = false | |
| 295 | + | p.respond = nil | |
| 296 | + | p.onHidden = nil | |
| 297 | + | p.mu.Unlock() | |
| 298 | + | ||
| 299 | + | for _, s := range p.signals { | |
| 300 | + | s.obj.DisconnectSignal(s.id) | |
| 301 | + | } | |
| 302 | + | p.signals = nil | |
| 303 | + | if p.controller != nil && p.outerBox != nil { | |
| 304 | + | p.outerBox.RemoveController(&p.controller.EventController) | |
| 305 | + | p.controller = nil | |
| 306 | + | } | |
| 307 | + | p.retainedCallbacks = nil | |
| 221 | 308 | } | |
| 222 | 309 | ||
| 223 | 310 | func (p *JSDialogPopup) createWidgets() error { | |
| @@ -241,7 +328,13 @@ func (p *JSDialogPopup) createWidgets() error { | |||
| 241 | 328 | return errNilWidget("jsDialogEntry") | |
| 242 | 329 | } | |
| 243 | 330 | p.input.AddCssClass("jsdialog-entry") | |
| 331 | + | p.input.SetMaxLength(jsDialogMaxTextRunes) | |
| 244 | 332 | p.input.SetVisible(false) | |
| 333 | + | // Enter in the entry = OK. Focused buttons activate themselves, so Enter on | |
| 334 | + | // Cancel/Stay still cancels. | |
| 335 | + | activateCb := func(gtk.Entry) { p.Dismiss(true) } | |
| 336 | + | p.retainedCallbacks = append(p.retainedCallbacks, activateCb) | |
| 337 | + | p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)}) | |
| 245 | 338 | ||
| 246 | 339 | btnRow, err := p.createButtonRow() | |
| 247 | 340 | if err != nil { | |
| @@ -276,7 +369,6 @@ func (p *JSDialogPopup) createContainers() error { | |||
| 276 | 369 | p.mainBox.AddCssClass("permission-popup-container") | |
| 277 | 370 | p.mainBox.SetHalign(gtk.AlignCenterValue) | |
| 278 | 371 | p.mainBox.SetValign(gtk.AlignCenterValue) | |
| 279 | - | p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1) | |
| 280 | 372 | return nil | |
| 281 | 373 | } | |
| 282 | 374 | ||
| @@ -319,18 +411,21 @@ func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) { | |||
| 319 | 411 | p.btnOK.AddCssClass("permission-popup-btn") | |
| 320 | 412 | p.btnOK.AddCssClass("permission-popup-btn-allow") | |
| 321 | 413 | ||
| 322 | - | cancelCb := func(_ gtk.Button) { p.dismiss(false) } | |
| 323 | - | okCb := func(_ gtk.Button) { p.dismiss(true) } | |
| 414 | + | cancelCb := func(_ gtk.Button) { p.Dismiss(false) } | |
| 415 | + | okCb := func(_ gtk.Button) { p.Dismiss(true) } | |
| 324 | 416 | p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb) | |
| 325 | - | p.btnCancel.ConnectClicked(&cancelCb) | |
| 326 | - | p.btnOK.ConnectClicked(&okCb) | |
| 417 | + | p.signals = append(p.signals, | |
| 418 | + | popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)}, | |
| 419 | + | popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)}, | |
| 420 | + | ) | |
| 327 | 421 | ||
| 328 | 422 | btnRow.Append(&p.btnCancel.Widget) | |
| 329 | 423 | btnRow.Append(&p.btnOK.Widget) | |
| 330 | 424 | return btnRow, nil | |
| 331 | 425 | } | |
| 332 | 426 | ||
| 333 | - | // attachKeyController maps Enter to OK and Escape to Cancel. | |
| 427 | + | // attachKeyController maps Escape to Cancel (capture phase, so the page never | |
| 428 | + | // sees it). Enter is not handled here: see the entry's activate handler. | |
| 334 | 429 | func (p *JSDialogPopup) attachKeyController() { | |
| 335 | 430 | controller := gtk.NewEventControllerKey() | |
| 336 | 431 | if controller == nil { | |
| @@ -338,17 +433,26 @@ func (p *JSDialogPopup) attachKeyController() { | |||
| 338 | 433 | } | |
| 339 | 434 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 340 | 435 | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | |
| 341 | - | switch keyval { | |
| 342 | - | case uint(gdk.KEY_Escape): | |
| 343 | - | p.dismiss(false) | |
| 344 | - | return true | |
| 345 | - | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 346 | - | p.dismiss(true) | |
| 436 | + | if keyval == uint(gdk.KEY_Escape) { | |
| 437 | + | p.Dismiss(false) | |
| 347 | 438 | return true | |
| 348 | 439 | } | |
| 349 | 440 | return false | |
| 350 | 441 | } | |
| 351 | 442 | p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb) | |
| 352 | - | controller.ConnectKeyPressed(&keyPressedCb) | |
| 443 | + | p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)}) | |
| 353 | 444 | p.outerBox.AddController(&controller.EventController) | |
| 445 | + | p.controller = controller | |
| 446 | + | ||
| 447 | + | // A dialog shown while its tab was in the background gets focus once mapped. | |
| 448 | + | mapCb := func(gtk.Widget) { | |
| 449 | + | p.mu.Lock() | |
| 450 | + | want := p.visible && p.wantFocus | |
| 451 | + | p.mu.Unlock() | |
| 452 | + | if want { | |
| 453 | + | p.grabDefaultFocus() | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | p.retainedCallbacks = append(p.retainedCallbacks, mapCb) | |
| 457 | + | p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)}) | |
| 354 | 458 | } | |
| @@ -10,15 +10,30 @@ import ( | |||||
| 10 | "github.com/bnema/puregotk/v4/pango" | 10 | "github.com/bnema/puregotk/v4/pango" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| 13 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | const ( | 16 | const ( | ||
| 16 | jsDialogMaxBodyHeight = 240 | 17 | jsDialogMaxBodyHeight = 240 | ||
| 17 | jsDialogMaxWidthChars = 60 | 18 | jsDialogMaxWidthChars = 60 | ||
| 18 | jsDialogMaxHostChars = 80 | 19 | jsDialogMaxHostChars = 80 | ||
| 19 | - | jsDialogDialogWidth = 420 | 20 | + | // jsDialogMaxTextRunes caps page-controlled text (message, prompt default) |
| 21 | + | // so a hostile page can't make GTK lay out megabytes of text. | |||
| 22 | + | jsDialogMaxTextRunes = 3000 | |||
| 23 | + | // jsDialogBeforeUnloadText is Dumber's own beforeunload text: CEF always | |||
| 24 | + | // passes a fixed string, and browsers no longer show page text here. | |||
| 25 | + | jsDialogBeforeUnloadText = "Changes you made may not be saved." | |||
| 20 | ) | 26 | ) | ||
| 21 | 27 | ||||
| 28 | + | // JSDialogSizeDefaults sizes the dialog box relative to its pane. | |||
| 29 | + | var JSDialogSizeDefaults = ModalSizeConfig{ | |||
| 30 | + | WidthPct: 0.9, | |||
| 31 | + | MaxWidth: 420, | |||
| 32 | + | TopMarginPct: 0.3, | |||
| 33 | + | FallbackWidth: 420, | |||
| 34 | + | FallbackHeight: 300, | |||
| 35 | + | } | |||
| 36 | + | ||||
| 22 | // jsDialogContent is the plain-text content of a JS dialog. | 37 | // jsDialogContent is the plain-text content of a JS dialog. | ||
| 23 | type jsDialogContent struct { | 38 | type jsDialogContent struct { | ||
| 24 | Heading string | 39 | Heading string | ||
| @@ -39,11 +54,21 @@ func jsDialogOriginLabel(origin string) string { | |||||
| 39 | u, err := url.Parse(origin) | 54 | u, err := url.Parse(origin) | ||
| 40 | if err != nil || u.Host == "" { | 55 | if err != nil || u.Host == "" { | ||
| 41 | if err == nil && u.Scheme != "" { | 56 | if err == nil && u.Scheme != "" { | ||
| 42 | - | return truncateRunes(u.Scheme+":", jsDialogMaxHostChars) | 57 | + | return truncateRunesLeft(u.Scheme+":", jsDialogMaxHostChars) |
| 43 | } | 58 | } | ||
| 44 | return "This page" | 59 | return "This page" | ||
| 45 | } | 60 | } | ||
| 46 | - | return truncateRunes(u.Host, jsDialogMaxHostChars) | 61 | + | // Keep the end of the host: the registrable domain is what identifies it. |
| 62 | + | return truncateRunesLeft(u.Host, jsDialogMaxHostChars) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // truncateRunesLeft keeps the last n runes, prefixing "…" when it cut. | |||
| 66 | + | func truncateRunesLeft(s string, n int) string { | |||
| 67 | + | r := []rune(s) | |||
| 68 | + | if len(r) <= n { | |||
| 69 | + | return s | |||
| 70 | + | } | |||
| 71 | + | return "…" + string(r[len(r)-n:]) | |||
| 47 | } | 72 | } | ||
| 48 | 73 | ||||
| 49 | func truncateRunes(s string, n int) string { | 74 | func truncateRunes(s string, n int) string { | ||
| @@ -58,16 +83,14 @@ func truncateRunes(s string, n int) string { | |||||
| 58 | // All strings are plain text (never Pango markup). | 83 | // All strings are plain text (never Pango markup). | ||
| 59 | func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent { | 84 | func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent { | ||
| 60 | says := jsDialogOriginLabel(req.Origin) + " says:" | 85 | says := jsDialogOriginLabel(req.Origin) + " says:" | ||
| 86 | + | msg := truncateRunes(req.Message, jsDialogMaxTextRunes) | |||
| 61 | switch req.Type { | 87 | switch req.Type { | ||
| 62 | case port.JSDialogConfirm: | 88 | case port.JSDialogConfirm: | ||
| 63 | - | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel"} | 89 | + | return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel"} |
| 64 | case port.JSDialogPrompt: | 90 | case port.JSDialogPrompt: | ||
| 65 | - | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true} | 91 | + | return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK", CancelLabel: "Cancel", ShowInput: true} |
| 66 | case port.JSDialogBeforeUnload: | 92 | case port.JSDialogBeforeUnload: | ||
| 67 | - | body := strings.TrimSpace(req.Message) | 93 | + | body := jsDialogBeforeUnloadText |
| 68 | - | if body == "" { | |||
| 69 | - | body = "Changes you made may not be saved." | |||
| 70 | - | } | |||
| 71 | heading := "Leave page?" | 94 | heading := "Leave page?" | ||
| 72 | if req.IsReload { | 95 | if req.IsReload { | ||
| 73 | heading = "Reload page?" | 96 | heading = "Reload page?" | ||
| @@ -79,7 +102,7 @@ func buildJSDialogContent(req port.JSDialogRequest) jsDialogContent { | |||||
| 79 | CancelLabel: "Stay", | 102 | CancelLabel: "Stay", | ||
| 80 | } | 103 | } | ||
| 81 | default: | 104 | default: | ||
| 82 | - | return jsDialogContent{Heading: says, Body: req.Message, OKLabel: "OK"} | 105 | + | return jsDialogContent{Heading: says, Body: msg, OKLabel: "OK"} |
| 83 | } | 106 | } | ||
| 84 | } | 107 | } | ||
| 85 | 108 | ||||
| @@ -97,17 +120,30 @@ type JSDialogPopup struct { | |||||
| 97 | btnOK *gtk.Button | 120 | btnOK *gtk.Button | ||
| 98 | btnCancel *gtk.Button | 121 | btnCancel *gtk.Button | ||
| 99 | 122 | ||||
| 100 | - | mu sync.Mutex | 123 | + | parent layout.OverlayWidget |
| 101 | - | visible bool | 124 | + | controller *gtk.EventControllerKey |
| 102 | - | respond func(ok bool, input string) | 125 | + | signals []popupSignal |
| 103 | - | onHidden func() | 126 | + | |
| 127 | + | mu sync.Mutex | |||
| 128 | + | visible bool | |||
| 129 | + | wantFocus bool // this dialog should own keyboard focus (pane is active) | |||
| 130 | + | destroyed bool | |||
| 131 | + | respond func(ok bool, input string) | |||
| 132 | + | onHidden func(hadFocus bool) | |||
| 104 | 133 | ||||
| 105 | retainedCallbacks []any | 134 | retainedCallbacks []any | ||
| 106 | } | 135 | } | ||
| 107 | 136 | ||||
| 137 | + | // popupSignal remembers a connected handler so Destroy can disconnect it. | |||
| 138 | + | type popupSignal struct { | |||
| 139 | + | obj interface{ DisconnectSignal(uint) } | |||
| 140 | + | id uint | |||
| 141 | + | } | |||
| 142 | + | ||||
| 108 | // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails. | 143 | // NewJSDialogPopup creates the popup (hidden). Returns nil if widget creation fails. | ||
| 109 | - | func NewJSDialogPopup() *JSDialogPopup { | 144 | + | // parent is the pane overlay used to size the dialog. |
| 110 | - | p := &JSDialogPopup{} | 145 | + | func NewJSDialogPopup(parent layout.OverlayWidget) *JSDialogPopup { |
| 146 | + | p := &JSDialogPopup{parent: parent} | |||
| 111 | if err := p.createWidgets(); err != nil { | 147 | if err := p.createWidgets(); err != nil { | ||
| 112 | return nil | 148 | return nil | ||
| 113 | } | 149 | } | ||
| @@ -123,25 +159,40 @@ func (p *JSDialogPopup) Widget() *gtk.Widget { | |||||
| 123 | return &p.outerBox.Widget | 159 | return &p.outerBox.Widget | ||
| 124 | } | 160 | } | ||
| 125 | 161 | ||||
| 126 | - | // SetOnHidden sets a callback run after the popup closes (e.g. to restore focus). | 162 | + | // SetOnHidden sets (or with nil clears) a callback run after the popup closes. hadFocus reports |
| 127 | - | func (p *JSDialogPopup) SetOnHidden(fn func()) { | 163 | + | // whether keyboard focus was inside the dialog when it closed, so the owner |
| 164 | + | // only restores focus when the dialog had taken it. | |||
| 165 | + | func (p *JSDialogPopup) SetOnHidden(fn func(hadFocus bool)) { | |||
| 128 | p.mu.Lock() | 166 | p.mu.Lock() | ||
| 129 | p.onHidden = fn | 167 | p.onHidden = fn | ||
| 130 | p.mu.Unlock() | 168 | p.mu.Unlock() | ||
| 131 | } | 169 | } | ||
| 132 | 170 | ||||
| 133 | // Show displays the dialog. respond is invoked once with the user's answer. | 171 | // Show displays the dialog. respond is invoked once with the user's answer. | ||
| 134 | - | // A dialog already visible is dismissed as canceled first. | 172 | + | // A dialog already visible is dismissed as canceled first. Keyboard focus is |
| 135 | - | func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string)) { | 173 | + | // only taken when focus is true (the pane is active); otherwise RequestFocus |
| 136 | - | p.dismiss(false) | 174 | + | // grabs it when the pane becomes active. |
| 175 | + | func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) { | |||
| 176 | + | p.Dismiss(false) | |||
| 137 | 177 | ||||
| 138 | content := buildJSDialogContent(req) | 178 | content := buildJSDialogContent(req) | ||
| 139 | 179 | ||||
| 140 | p.mu.Lock() | 180 | p.mu.Lock() | ||
| 181 | + | if p.destroyed { | |||
| 182 | + | p.mu.Unlock() | |||
| 183 | + | if respond != nil { | |||
| 184 | + | respond(false, "") | |||
| 185 | + | } | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 141 | p.visible = true | 188 | p.visible = true | ||
| 189 | + | p.wantFocus = focus | |||
| 142 | p.respond = respond | 190 | p.respond = respond | ||
| 143 | p.mu.Unlock() | 191 | p.mu.Unlock() | ||
| 144 | 192 | ||||
| 193 | + | width, _ := CalculateModalDimensionsWithScale(p.parent, JSDialogSizeDefaults, uiScale) | |||
| 194 | + | p.mainBox.SetSizeRequest(width, -1) | |||
| 195 | + | ||||
| 145 | p.headingLabel.SetText(content.Heading) | 196 | p.headingLabel.SetText(content.Heading) | ||
| 146 | p.bodyLabel.SetText(content.Body) | 197 | p.bodyLabel.SetText(content.Body) | ||
| 147 | p.bodyLabel.SetVisible(content.Body != "") | 198 | p.bodyLabel.SetVisible(content.Body != "") | ||
| @@ -152,18 +203,38 @@ func (p *JSDialogPopup) Show(req port.JSDialogRequest, respond func(ok bool, inp | |||||
| 152 | } | 203 | } | ||
| 153 | p.input.SetVisible(content.ShowInput) | 204 | p.input.SetVisible(content.ShowInput) | ||
| 154 | if content.ShowInput { | 205 | if content.ShowInput { | ||
| 155 | - | p.input.SetText(req.DefaultPrompt) | 206 | + | p.input.SetText(truncateRunes(req.DefaultPrompt, jsDialogMaxTextRunes)) |
| 156 | } else { | 207 | } else { | ||
| 157 | p.input.SetText("") | 208 | p.input.SetText("") | ||
| 158 | } | 209 | } | ||
| 159 | 210 | ||||
| 160 | p.outerBox.SetVisible(true) | 211 | p.outerBox.SetVisible(true) | ||
| 161 | - | if content.ShowInput { | 212 | + | if focus { |
| 213 | + | p.grabDefaultFocus() | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // RequestFocus marks the dialog as the focus owner and grabs focus if shown. | |||
| 218 | + | // Called when the owning pane becomes active. | |||
| 219 | + | func (p *JSDialogPopup) RequestFocus() { | |||
| 220 | + | p.mu.Lock() | |||
| 221 | + | if !p.visible || p.destroyed { | |||
| 222 | + | p.mu.Unlock() | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | p.wantFocus = true | |||
| 226 | + | p.mu.Unlock() | |||
| 227 | + | p.grabDefaultFocus() | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // grabDefaultFocus focuses the entry (prompt) or the OK button. | |||
| 231 | + | func (p *JSDialogPopup) grabDefaultFocus() { | |||
| 232 | + | if p.input.GetVisible() { | |||
| 162 | p.input.GrabFocus() | 233 | p.input.GrabFocus() | ||
| 163 | p.input.SelectRegion(0, -1) | 234 | p.input.SelectRegion(0, -1) | ||
| 164 | - | } else { | 235 | + | return |
| 165 | - | p.btnOK.GrabFocus() | |||
| 166 | } | 236 | } | ||
| 237 | + | p.btnOK.GrabFocus() | |||
| 167 | } | 238 | } | ||
| 168 | 239 | ||||
| 169 | // Hide closes the popup without invoking the response callback. It is used when | 240 | // Hide closes the popup without invoking the response callback. It is used when | ||
| @@ -172,52 +243,68 @@ func (p *JSDialogPopup) Hide() { | |||||
| 172 | if p == nil { | 243 | if p == nil { | ||
| 173 | return | 244 | return | ||
| 174 | } | 245 | } | ||
| 175 | - | p.mu.Lock() | 246 | + | p.close(nil) |
| 176 | - | wasVisible := p.visible | |||
| 177 | - | p.visible = false | |||
| 178 | - | p.respond = nil | |||
| 179 | - | hidden := p.onHidden | |||
| 180 | - | p.mu.Unlock() | |||
| 181 | - | if !wasVisible { | |||
| 182 | - | return | |||
| 183 | - | } | |||
| 184 | - | p.outerBox.SetVisible(false) | |||
| 185 | - | if hidden != nil { | |||
| 186 | - | hidden() | |||
| 187 | - | } | |||
| 188 | } | 247 | } | ||
| 189 | 248 | ||||
| 190 | - | // IsVisible reports whether a dialog is displayed. | 249 | + | // Dismiss closes the popup and answers with ok. No-op when hidden. |
| 191 | - | func (p *JSDialogPopup) IsVisible() bool { | 250 | + | func (p *JSDialogPopup) Dismiss(ok bool) { |
| 192 | - | p.mu.Lock() | 251 | + | p.close(&ok) |
| 193 | - | defer p.mu.Unlock() | |||
| 194 | - | return p.visible | |||
| 195 | } | 252 | } | ||
| 196 | 253 | ||||
| 197 | - | // dismiss closes the popup and answers. Safe to call when hidden (no-op). | 254 | + | // close hides the popup. When answer is non-nil the response callback is run. |
| 198 | - | func (p *JSDialogPopup) dismiss(ok bool) { | 255 | + | func (p *JSDialogPopup) close(answer *bool) { |
| 199 | p.mu.Lock() | 256 | p.mu.Lock() | ||
| 200 | if !p.visible { | 257 | if !p.visible { | ||
| 201 | p.mu.Unlock() | 258 | p.mu.Unlock() | ||
| 202 | return | 259 | return | ||
| 203 | } | 260 | } | ||
| 204 | p.visible = false | 261 | p.visible = false | ||
| 262 | + | p.wantFocus = false | |||
| 205 | respond := p.respond | 263 | respond := p.respond | ||
| 206 | p.respond = nil | 264 | p.respond = nil | ||
| 207 | hidden := p.onHidden | 265 | hidden := p.onHidden | ||
| 208 | p.mu.Unlock() | 266 | p.mu.Unlock() | ||
| 209 | 267 | ||||
| 268 | + | hadFocus := p.outerBox.GetFocusChild() != nil | |||
| 210 | text := "" | 269 | text := "" | ||
| 211 | - | if ok && p.input.GetVisible() { | 270 | + | if answer != nil && *answer && p.input.GetVisible() { |
| 212 | text = p.input.GetText() | 271 | text = p.input.GetText() | ||
| 213 | } | 272 | } | ||
| 214 | p.outerBox.SetVisible(false) | 273 | p.outerBox.SetVisible(false) | ||
| 215 | - | if respond != nil { | 274 | + | if answer != nil && respond != nil { |
| 216 | - | respond(ok, text) | 275 | + | respond(*answer, text) |
| 217 | } | 276 | } | ||
| 218 | if hidden != nil { | 277 | if hidden != nil { | ||
| 219 | - | hidden() | 278 | + | hidden(hadFocus) |
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // Destroy disconnects every handler and drops callbacks. The popup must not be | |||
| 283 | + | // used afterwards. Call after the dialog was dismissed. | |||
| 284 | + | func (p *JSDialogPopup) Destroy() { | |||
| 285 | + | if p == nil { | |||
| 286 | + | return | |||
| 220 | } | 287 | } | ||
| 288 | + | p.mu.Lock() | |||
| 289 | + | if p.destroyed { | |||
| 290 | + | p.mu.Unlock() | |||
| 291 | + | return | |||
| 292 | + | } | |||
| 293 | + | p.destroyed = true | |||
| 294 | + | p.visible = false | |||
| 295 | + | p.respond = nil | |||
| 296 | + | p.onHidden = nil | |||
| 297 | + | p.mu.Unlock() | |||
| 298 | + | ||||
| 299 | + | for _, s := range p.signals { | |||
| 300 | + | s.obj.DisconnectSignal(s.id) | |||
| 301 | + | } | |||
| 302 | + | p.signals = nil | |||
| 303 | + | if p.controller != nil && p.outerBox != nil { | |||
| 304 | + | p.outerBox.RemoveController(&p.controller.EventController) | |||
| 305 | + | p.controller = nil | |||
| 306 | + | } | |||
| 307 | + | p.retainedCallbacks = nil | |||
| 221 | } | 308 | } | ||
| 222 | 309 | ||||
| 223 | func (p *JSDialogPopup) createWidgets() error { | 310 | func (p *JSDialogPopup) createWidgets() error { | ||
| @@ -241,7 +328,13 @@ func (p *JSDialogPopup) createWidgets() error { | |||||
| 241 | return errNilWidget("jsDialogEntry") | 328 | return errNilWidget("jsDialogEntry") | ||
| 242 | } | 329 | } | ||
| 243 | p.input.AddCssClass("jsdialog-entry") | 330 | p.input.AddCssClass("jsdialog-entry") | ||
| 331 | + | p.input.SetMaxLength(jsDialogMaxTextRunes) | |||
| 244 | p.input.SetVisible(false) | 332 | p.input.SetVisible(false) | ||
| 333 | + | // Enter in the entry = OK. Focused buttons activate themselves, so Enter on | |||
| 334 | + | // Cancel/Stay still cancels. | |||
| 335 | + | activateCb := func(gtk.Entry) { p.Dismiss(true) } | |||
| 336 | + | p.retainedCallbacks = append(p.retainedCallbacks, activateCb) | |||
| 337 | + | p.signals = append(p.signals, popupSignal{p.input, p.input.ConnectActivate(&activateCb)}) | |||
| 245 | 338 | ||||
| 246 | btnRow, err := p.createButtonRow() | 339 | btnRow, err := p.createButtonRow() | ||
| 247 | if err != nil { | 340 | if err != nil { | ||
| @@ -276,7 +369,6 @@ func (p *JSDialogPopup) createContainers() error { | |||||
| 276 | p.mainBox.AddCssClass("permission-popup-container") | 369 | p.mainBox.AddCssClass("permission-popup-container") | ||
| 277 | p.mainBox.SetHalign(gtk.AlignCenterValue) | 370 | p.mainBox.SetHalign(gtk.AlignCenterValue) | ||
| 278 | p.mainBox.SetValign(gtk.AlignCenterValue) | 371 | p.mainBox.SetValign(gtk.AlignCenterValue) | ||
| 279 | - | p.mainBox.SetSizeRequest(jsDialogDialogWidth, -1) | |||
| 280 | return nil | 372 | return nil | ||
| 281 | } | 373 | } | ||
| 282 | 374 | ||||
| @@ -319,18 +411,21 @@ func (p *JSDialogPopup) createButtonRow() (*gtk.Box, error) { | |||||
| 319 | p.btnOK.AddCssClass("permission-popup-btn") | 411 | p.btnOK.AddCssClass("permission-popup-btn") | ||
| 320 | p.btnOK.AddCssClass("permission-popup-btn-allow") | 412 | p.btnOK.AddCssClass("permission-popup-btn-allow") | ||
| 321 | 413 | ||||
| 322 | - | cancelCb := func(_ gtk.Button) { p.dismiss(false) } | 414 | + | cancelCb := func(_ gtk.Button) { p.Dismiss(false) } |
| 323 | - | okCb := func(_ gtk.Button) { p.dismiss(true) } | 415 | + | okCb := func(_ gtk.Button) { p.Dismiss(true) } |
| 324 | p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb) | 416 | p.retainedCallbacks = append(p.retainedCallbacks, cancelCb, okCb) | ||
| 325 | - | p.btnCancel.ConnectClicked(&cancelCb) | 417 | + | p.signals = append(p.signals, |
| 326 | - | p.btnOK.ConnectClicked(&okCb) | 418 | + | popupSignal{p.btnCancel, p.btnCancel.ConnectClicked(&cancelCb)}, |
| 419 | + | popupSignal{p.btnOK, p.btnOK.ConnectClicked(&okCb)}, | |||
| 420 | + | ) | |||
| 327 | 421 | ||||
| 328 | btnRow.Append(&p.btnCancel.Widget) | 422 | btnRow.Append(&p.btnCancel.Widget) | ||
| 329 | btnRow.Append(&p.btnOK.Widget) | 423 | btnRow.Append(&p.btnOK.Widget) | ||
| 330 | return btnRow, nil | 424 | return btnRow, nil | ||
| 331 | } | 425 | } | ||
| 332 | 426 | ||||
| 333 | - | // attachKeyController maps Enter to OK and Escape to Cancel. | 427 | + | // attachKeyController maps Escape to Cancel (capture phase, so the page never |
| 428 | + | // sees it). Enter is not handled here: see the entry's activate handler. | |||
| 334 | func (p *JSDialogPopup) attachKeyController() { | 429 | func (p *JSDialogPopup) attachKeyController() { | ||
| 335 | controller := gtk.NewEventControllerKey() | 430 | controller := gtk.NewEventControllerKey() | ||
| 336 | if controller == nil { | 431 | if controller == nil { | ||
| @@ -338,17 +433,26 @@ func (p *JSDialogPopup) attachKeyController() { | |||||
| 338 | } | 433 | } | ||
| 339 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | 434 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | ||
| 340 | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | 435 | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | ||
| 341 | - | switch keyval { | 436 | + | if keyval == uint(gdk.KEY_Escape) { |
| 342 | - | case uint(gdk.KEY_Escape): | 437 | + | p.Dismiss(false) |
| 343 | - | p.dismiss(false) | |||
| 344 | - | return true | |||
| 345 | - | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 346 | - | p.dismiss(true) | |||
| 347 | return true | 438 | return true | ||
| 348 | } | 439 | } | ||
| 349 | return false | 440 | return false | ||
| 350 | } | 441 | } | ||
| 351 | p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb) | 442 | p.retainedCallbacks = append(p.retainedCallbacks, keyPressedCb) | ||
| 352 | - | controller.ConnectKeyPressed(&keyPressedCb) | 443 | + | p.signals = append(p.signals, popupSignal{controller, controller.ConnectKeyPressed(&keyPressedCb)}) |
| 353 | p.outerBox.AddController(&controller.EventController) | 444 | p.outerBox.AddController(&controller.EventController) | ||
| 445 | + | p.controller = controller | |||
| 446 | + | ||||
| 447 | + | // A dialog shown while its tab was in the background gets focus once mapped. | |||
| 448 | + | mapCb := func(gtk.Widget) { | |||
| 449 | + | p.mu.Lock() | |||
| 450 | + | want := p.visible && p.wantFocus | |||
| 451 | + | p.mu.Unlock() | |||
| 452 | + | if want { | |||
| 453 | + | p.grabDefaultFocus() | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | p.retainedCallbacks = append(p.retainedCallbacks, mapCb) | |||
| 457 | + | p.signals = append(p.signals, popupSignal{&p.outerBox.Widget, p.outerBox.ConnectMap(&mapCb)}) | |||
| 354 | } | 458 | } | ||
M internal/ui/component/pane_view.go +82 -30
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | "github.com/bnema/dumber/internal/ui/input" | |
| 12 | 12 | "github.com/bnema/dumber/internal/ui/layout" | |
| 13 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // CSS class applied to the active pane's border overlay. | |
| @@ -18,19 +19,20 @@ const activePaneClass = "pane-active" | |||
| 18 | 19 | // PaneView is a container for a single WebView with active state indication. | |
| 19 | 20 | // It uses an overlay to display a border around the active pane. | |
| 20 | 21 | type PaneView struct { | |
| 21 | - | ctx context.Context | |
| 22 | - | factory layout.WidgetFactory | |
| 23 | - | overlay layout.OverlayWidget | |
| 24 | - | webViewWidget layout.Widget // The actual WebView widget | |
| 25 | - | borderBox layout.BoxWidget // Border overlay for active indication | |
| 26 | - | progressBar *ProgressBar // Loading progress indicator | |
| 27 | - | toaster *Toaster // Toast notification overlay | |
| 28 | - | linkStatus *LinkStatusOverlay // Link hover URL overlay | |
| 29 | - | loading *LoadingSkeleton // Placeholder shown until WebView paints | |
| 30 | - | jsDialog *JSDialogPopup // JS alert/confirm/prompt/beforeunload overlay | |
| 31 | - | jsDialogWidth layout.Widget // overlay wrapper of jsDialog, for removal | |
| 32 | - | paneID entity.PaneID | |
| 33 | - | isActive bool | |
| 22 | + | ctx context.Context | |
| 23 | + | factory layout.WidgetFactory | |
| 24 | + | overlay layout.OverlayWidget | |
| 25 | + | webViewWidget layout.Widget // The actual WebView widget | |
| 26 | + | borderBox layout.BoxWidget // Border overlay for active indication | |
| 27 | + | progressBar *ProgressBar // Loading progress indicator | |
| 28 | + | toaster *Toaster // Toast notification overlay | |
| 29 | + | linkStatus *LinkStatusOverlay // Link hover URL overlay | |
| 30 | + | loading *LoadingSkeleton // Placeholder shown until WebView paints | |
| 31 | + | jsDialog jsDialogView // JS alert/confirm/prompt/beforeunload overlay | |
| 32 | + | jsDialogWidget layout.Widget // overlay wrapper of jsDialog, for removal | |
| 33 | + | newJSDialog func(layout.OverlayWidget) jsDialogView | |
| 34 | + | paneID entity.PaneID | |
| 35 | + | isActive bool | |
| 34 | 36 | ||
| 35 | 37 | // Vim Mode input ownership; its border and pulse belong to modeFrame. | |
| 36 | 38 | vimMode bool | |
| @@ -104,9 +106,8 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit | |||
| 104 | 106 | // Active panes display a visual border indicator. | |
| 105 | 107 | func (pv *PaneView) SetActive(active bool) { | |
| 106 | 108 | pv.mu.Lock() | |
| 107 | - | defer pv.mu.Unlock() | |
| 108 | - | ||
| 109 | 109 | if pv.isActive == active { | |
| 110 | + | pv.mu.Unlock() | |
| 110 | 111 | return | |
| 111 | 112 | } | |
| 112 | 113 | ||
| @@ -117,6 +118,13 @@ func (pv *PaneView) SetActive(active bool) { | |||
| 117 | 118 | } else { | |
| 118 | 119 | pv.borderBox.RemoveCssClass(activePaneClass) | |
| 119 | 120 | } | |
| 121 | + | popup := pv.jsDialog | |
| 122 | + | pv.mu.Unlock() | |
| 123 | + | ||
| 124 | + | // A dialog raised while this pane was inactive takes focus now. | |
| 125 | + | if active && popup != nil { | |
| 126 | + | popup.RequestFocus() | |
| 127 | + | } | |
| 120 | 128 | } | |
| 121 | 129 | ||
| 122 | 130 | // IsActive returns whether this pane is currently active. | |
| @@ -550,8 +558,28 @@ func (pv *PaneView) IsVimMode() bool { | |||
| 550 | 558 | // After calling Cleanup, the PaneView should not be reused. | |
| 551 | 559 | func (pv *PaneView) Cleanup() { | |
| 552 | 560 | pv.mu.Lock() | |
| 553 | - | defer pv.mu.Unlock() | |
| 561 | + | // The WebView may outlive this view (workspace rebuild), so an open JS | |
| 562 | + | // dialog must be answered, not dropped. The hidden callback re-locks | |
| 563 | + | // pv.mu, so detach it here and dismiss after unlocking. | |
| 564 | + | popup, popupWidget := pv.jsDialog, pv.jsDialogWidget | |
| 565 | + | if popup != nil { | |
| 566 | + | popup.SetOnHidden(nil) | |
| 567 | + | } | |
| 568 | + | pv.jsDialog, pv.jsDialogWidget = nil, nil | |
| 569 | + | pv.cleanupLocked() | |
| 570 | + | pv.mu.Unlock() | |
| 554 | 571 | ||
| 572 | + | if popup != nil { | |
| 573 | + | popup.Dismiss(false) | |
| 574 | + | popup.Destroy() | |
| 575 | + | if popupWidget != nil { | |
| 576 | + | pv.overlay.RemoveOverlay(popupWidget) | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | // cleanupLocked releases everything except the JS dialog. Caller holds pv.mu. | |
| 582 | + | func (pv *PaneView) cleanupLocked() { | |
| 555 | 583 | // Clear callbacks to prevent use-after-free | |
| 556 | 584 | pv.onFocusIn = nil | |
| 557 | 585 | pv.onFocusOut = nil | |
| @@ -588,29 +616,50 @@ func (pv *PaneView) Cleanup() { | |||
| 588 | 616 | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | |
| 589 | 617 | pv.linkStatus = nil | |
| 590 | 618 | } | |
| 591 | - | if pv.jsDialogWidth != nil { | |
| 592 | - | pv.overlay.RemoveOverlay(pv.jsDialogWidth) | |
| 593 | - | pv.jsDialogWidth = nil | |
| 594 | - | pv.jsDialog = nil | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | // jsDialogView is the JS dialog popup as seen by PaneView (a seam for tests). | |
| 622 | + | type jsDialogView interface { | |
| 623 | + | Widget() *gtk.Widget | |
| 624 | + | Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) | |
| 625 | + | Hide() | |
| 626 | + | Dismiss(ok bool) | |
| 627 | + | RequestFocus() | |
| 628 | + | SetOnHidden(fn func(hadFocus bool)) | |
| 629 | + | Destroy() | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | func newJSDialogPopupView(parent layout.OverlayWidget) jsDialogView { | |
| 633 | + | if p := NewJSDialogPopup(parent); p != nil { | |
| 634 | + | return p | |
| 595 | 635 | } | |
| 636 | + | return nil | |
| 596 | 637 | } | |
| 597 | 638 | ||
| 598 | 639 | // ensureJSDialog creates the JS dialog popup lazily on first use. | |
| 599 | 640 | // Must be called with write lock held. | |
| 600 | - | func (pv *PaneView) ensureJSDialog() *JSDialogPopup { | |
| 641 | + | func (pv *PaneView) ensureJSDialog() jsDialogView { | |
| 601 | 642 | if pv.jsDialog != nil { | |
| 602 | 643 | return pv.jsDialog | |
| 603 | 644 | } | |
| 604 | - | popup := NewJSDialogPopup() | |
| 645 | + | create := pv.newJSDialog | |
| 646 | + | if create == nil { | |
| 647 | + | create = newJSDialogPopupView | |
| 648 | + | } | |
| 649 | + | popup := create(pv.overlay) | |
| 605 | 650 | if popup == nil { | |
| 606 | 651 | return nil | |
| 607 | 652 | } | |
| 608 | 653 | w := pv.factory.WrapWidget(popup.Widget()) | |
| 609 | 654 | pv.overlay.AddOverlay(w) | |
| 610 | - | pv.overlay.SetClipOverlay(w, false) | |
| 655 | + | // Clip so the scrim stays inside the pane; it must not affect layout. | |
| 656 | + | pv.overlay.SetClipOverlay(w, true) | |
| 611 | 657 | pv.overlay.SetMeasureOverlay(w, false) | |
| 612 | - | popup.SetOnHidden(func() { | |
| 613 | - | // Return keyboard focus to the page once the dialog is gone. | |
| 658 | + | popup.SetOnHidden(func(hadFocus bool) { | |
| 659 | + | // Give focus back to the page only if the dialog had taken it. | |
| 660 | + | if !hadFocus { | |
| 661 | + | return | |
| 662 | + | } | |
| 614 | 663 | pv.mu.RLock() | |
| 615 | 664 | wvw := pv.webViewWidget | |
| 616 | 665 | pv.mu.RUnlock() | |
| @@ -619,22 +668,25 @@ func (pv *PaneView) ensureJSDialog() *JSDialogPopup { | |||
| 619 | 668 | } | |
| 620 | 669 | }) | |
| 621 | 670 | pv.jsDialog = popup | |
| 622 | - | pv.jsDialogWidth = w | |
| 671 | + | pv.jsDialogWidget = w | |
| 623 | 672 | return popup | |
| 624 | 673 | } | |
| 625 | 674 | ||
| 626 | 675 | // ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog | |
| 627 | 676 | // lives in the pane's overlay, so it never blocks other panes; if the pane is | |
| 628 | - | // in a background tab it becomes visible when that tab is shown. respond is | |
| 629 | - | // invoked once with the user's answer. Returns false if the popup is unavailable. | |
| 630 | - | func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string)) bool { | |
| 677 | + | // in a background tab it becomes visible when that tab is shown, and it only | |
| 678 | + | // takes keyboard focus while the pane is active (otherwise when it becomes | |
| 679 | + | // active). respond is invoked once with the user's answer. Returns false if the | |
| 680 | + | // popup is unavailable. | |
| 681 | + | func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string), uiScale float64) bool { | |
| 631 | 682 | pv.mu.Lock() | |
| 632 | 683 | popup := pv.ensureJSDialog() | |
| 684 | + | focus := pv.isActive | |
| 633 | 685 | pv.mu.Unlock() | |
| 634 | 686 | if popup == nil { | |
| 635 | 687 | return false | |
| 636 | 688 | } | |
| 637 | - | popup.Show(req, respond) | |
| 689 | + | popup.Show(req, respond, focus, uiScale) | |
| 638 | 690 | return true | |
| 639 | 691 | } | |
| 640 | 692 | ||
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 11 | "github.com/bnema/dumber/internal/ui/input" | 11 | "github.com/bnema/dumber/internal/ui/input" | ||
| 12 | "github.com/bnema/dumber/internal/ui/layout" | 12 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 13 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | // CSS class applied to the active pane's border overlay. | 16 | // CSS class applied to the active pane's border overlay. | ||
| @@ -18,19 +19,20 @@ const activePaneClass = "pane-active" | |||||
| 18 | // PaneView is a container for a single WebView with active state indication. | 19 | // PaneView is a container for a single WebView with active state indication. | ||
| 19 | // It uses an overlay to display a border around the active pane. | 20 | // It uses an overlay to display a border around the active pane. | ||
| 20 | type PaneView struct { | 21 | type PaneView struct { | ||
| 21 | - | ctx context.Context | 22 | + | ctx context.Context |
| 22 | - | factory layout.WidgetFactory | 23 | + | factory layout.WidgetFactory |
| 23 | - | overlay layout.OverlayWidget | 24 | + | overlay layout.OverlayWidget |
| 24 | - | webViewWidget layout.Widget // The actual WebView widget | 25 | + | webViewWidget layout.Widget // The actual WebView widget |
| 25 | - | borderBox layout.BoxWidget // Border overlay for active indication | 26 | + | borderBox layout.BoxWidget // Border overlay for active indication |
| 26 | - | progressBar *ProgressBar // Loading progress indicator | 27 | + | progressBar *ProgressBar // Loading progress indicator |
| 27 | - | toaster *Toaster // Toast notification overlay | 28 | + | toaster *Toaster // Toast notification overlay |
| 28 | - | linkStatus *LinkStatusOverlay // Link hover URL overlay | 29 | + | linkStatus *LinkStatusOverlay // Link hover URL overlay |
| 29 | - | loading *LoadingSkeleton // Placeholder shown until WebView paints | 30 | + | loading *LoadingSkeleton // Placeholder shown until WebView paints |
| 30 | - | jsDialog *JSDialogPopup // JS alert/confirm/prompt/beforeunload overlay | 31 | + | jsDialog jsDialogView // JS alert/confirm/prompt/beforeunload overlay |
| 31 | - | jsDialogWidth layout.Widget // overlay wrapper of jsDialog, for removal | 32 | + | jsDialogWidget layout.Widget // overlay wrapper of jsDialog, for removal |
| 32 | - | paneID entity.PaneID | 33 | + | newJSDialog func(layout.OverlayWidget) jsDialogView |
| 33 | - | isActive bool | 34 | + | paneID entity.PaneID |
| 35 | + | isActive bool | |||
| 34 | 36 | ||||
| 35 | // Vim Mode input ownership; its border and pulse belong to modeFrame. | 37 | // Vim Mode input ownership; its border and pulse belong to modeFrame. | ||
| 36 | vimMode bool | 38 | vimMode bool | ||
| @@ -104,9 +106,8 @@ func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entit | |||||
| 104 | // Active panes display a visual border indicator. | 106 | // Active panes display a visual border indicator. | ||
| 105 | func (pv *PaneView) SetActive(active bool) { | 107 | func (pv *PaneView) SetActive(active bool) { | ||
| 106 | pv.mu.Lock() | 108 | pv.mu.Lock() | ||
| 107 | - | defer pv.mu.Unlock() | |||
| 108 | - | ||||
| 109 | if pv.isActive == active { | 109 | if pv.isActive == active { | ||
| 110 | + | pv.mu.Unlock() | |||
| 110 | return | 111 | return | ||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| @@ -117,6 +118,13 @@ func (pv *PaneView) SetActive(active bool) { | |||||
| 117 | } else { | 118 | } else { | ||
| 118 | pv.borderBox.RemoveCssClass(activePaneClass) | 119 | pv.borderBox.RemoveCssClass(activePaneClass) | ||
| 119 | } | 120 | } | ||
| 121 | + | popup := pv.jsDialog | |||
| 122 | + | pv.mu.Unlock() | |||
| 123 | + | ||||
| 124 | + | // A dialog raised while this pane was inactive takes focus now. | |||
| 125 | + | if active && popup != nil { | |||
| 126 | + | popup.RequestFocus() | |||
| 127 | + | } | |||
| 120 | } | 128 | } | ||
| 121 | 129 | ||||
| 122 | // IsActive returns whether this pane is currently active. | 130 | // IsActive returns whether this pane is currently active. | ||
| @@ -550,8 +558,28 @@ func (pv *PaneView) IsVimMode() bool { | |||||
| 550 | // After calling Cleanup, the PaneView should not be reused. | 558 | // After calling Cleanup, the PaneView should not be reused. | ||
| 551 | func (pv *PaneView) Cleanup() { | 559 | func (pv *PaneView) Cleanup() { | ||
| 552 | pv.mu.Lock() | 560 | pv.mu.Lock() | ||
| 553 | - | defer pv.mu.Unlock() | 561 | + | // The WebView may outlive this view (workspace rebuild), so an open JS |
| 562 | + | // dialog must be answered, not dropped. The hidden callback re-locks | |||
| 563 | + | // pv.mu, so detach it here and dismiss after unlocking. | |||
| 564 | + | popup, popupWidget := pv.jsDialog, pv.jsDialogWidget | |||
| 565 | + | if popup != nil { | |||
| 566 | + | popup.SetOnHidden(nil) | |||
| 567 | + | } | |||
| 568 | + | pv.jsDialog, pv.jsDialogWidget = nil, nil | |||
| 569 | + | pv.cleanupLocked() | |||
| 570 | + | pv.mu.Unlock() | |||
| 554 | 571 | ||||
| 572 | + | if popup != nil { | |||
| 573 | + | popup.Dismiss(false) | |||
| 574 | + | popup.Destroy() | |||
| 575 | + | if popupWidget != nil { | |||
| 576 | + | pv.overlay.RemoveOverlay(popupWidget) | |||
| 577 | + | } | |||
| 578 | + | } | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | // cleanupLocked releases everything except the JS dialog. Caller holds pv.mu. | |||
| 582 | + | func (pv *PaneView) cleanupLocked() { | |||
| 555 | // Clear callbacks to prevent use-after-free | 583 | // Clear callbacks to prevent use-after-free | ||
| 556 | pv.onFocusIn = nil | 584 | pv.onFocusIn = nil | ||
| 557 | pv.onFocusOut = nil | 585 | pv.onFocusOut = nil | ||
| @@ -588,29 +616,50 @@ func (pv *PaneView) Cleanup() { | |||||
| 588 | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | 616 | pv.overlay.RemoveOverlay(pv.linkStatus.Widget()) | ||
| 589 | pv.linkStatus = nil | 617 | pv.linkStatus = nil | ||
| 590 | } | 618 | } | ||
| 591 | - | if pv.jsDialogWidth != nil { | 619 | + | } |
| 592 | - | pv.overlay.RemoveOverlay(pv.jsDialogWidth) | 620 | + | |
| 593 | - | pv.jsDialogWidth = nil | 621 | + | // jsDialogView is the JS dialog popup as seen by PaneView (a seam for tests). |
| 594 | - | pv.jsDialog = nil | 622 | + | type jsDialogView interface { |
| 623 | + | Widget() *gtk.Widget | |||
| 624 | + | Show(req port.JSDialogRequest, respond func(ok bool, input string), focus bool, uiScale float64) | |||
| 625 | + | Hide() | |||
| 626 | + | Dismiss(ok bool) | |||
| 627 | + | RequestFocus() | |||
| 628 | + | SetOnHidden(fn func(hadFocus bool)) | |||
| 629 | + | Destroy() | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | func newJSDialogPopupView(parent layout.OverlayWidget) jsDialogView { | |||
| 633 | + | if p := NewJSDialogPopup(parent); p != nil { | |||
| 634 | + | return p | |||
| 595 | } | 635 | } | ||
| 636 | + | return nil | |||
| 596 | } | 637 | } | ||
| 597 | 638 | ||||
| 598 | // ensureJSDialog creates the JS dialog popup lazily on first use. | 639 | // ensureJSDialog creates the JS dialog popup lazily on first use. | ||
| 599 | // Must be called with write lock held. | 640 | // Must be called with write lock held. | ||
| 600 | - | func (pv *PaneView) ensureJSDialog() *JSDialogPopup { | 641 | + | func (pv *PaneView) ensureJSDialog() jsDialogView { |
| 601 | if pv.jsDialog != nil { | 642 | if pv.jsDialog != nil { | ||
| 602 | return pv.jsDialog | 643 | return pv.jsDialog | ||
| 603 | } | 644 | } | ||
| 604 | - | popup := NewJSDialogPopup() | 645 | + | create := pv.newJSDialog |
| 646 | + | if create == nil { | |||
| 647 | + | create = newJSDialogPopupView | |||
| 648 | + | } | |||
| 649 | + | popup := create(pv.overlay) | |||
| 605 | if popup == nil { | 650 | if popup == nil { | ||
| 606 | return nil | 651 | return nil | ||
| 607 | } | 652 | } | ||
| 608 | w := pv.factory.WrapWidget(popup.Widget()) | 653 | w := pv.factory.WrapWidget(popup.Widget()) | ||
| 609 | pv.overlay.AddOverlay(w) | 654 | pv.overlay.AddOverlay(w) | ||
| 610 | - | pv.overlay.SetClipOverlay(w, false) | 655 | + | // Clip so the scrim stays inside the pane; it must not affect layout. |
| 656 | + | pv.overlay.SetClipOverlay(w, true) | |||
| 611 | pv.overlay.SetMeasureOverlay(w, false) | 657 | pv.overlay.SetMeasureOverlay(w, false) | ||
| 612 | - | popup.SetOnHidden(func() { | 658 | + | popup.SetOnHidden(func(hadFocus bool) { |
| 613 | - | // Return keyboard focus to the page once the dialog is gone. | 659 | + | // Give focus back to the page only if the dialog had taken it. |
| 660 | + | if !hadFocus { | |||
| 661 | + | return | |||
| 662 | + | } | |||
| 614 | pv.mu.RLock() | 663 | pv.mu.RLock() | ||
| 615 | wvw := pv.webViewWidget | 664 | wvw := pv.webViewWidget | ||
| 616 | pv.mu.RUnlock() | 665 | pv.mu.RUnlock() | ||
| @@ -619,22 +668,25 @@ func (pv *PaneView) ensureJSDialog() *JSDialogPopup { | |||||
| 619 | } | 668 | } | ||
| 620 | }) | 669 | }) | ||
| 621 | pv.jsDialog = popup | 670 | pv.jsDialog = popup | ||
| 622 | - | pv.jsDialogWidth = w | 671 | + | pv.jsDialogWidget = w |
| 623 | return popup | 672 | return popup | ||
| 624 | } | 673 | } | ||
| 625 | 674 | ||||
| 626 | // ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog | 675 | // ShowJSDialog displays a JavaScript dialog scoped to this pane. The dialog | ||
| 627 | // lives in the pane's overlay, so it never blocks other panes; if the pane is | 676 | // lives in the pane's overlay, so it never blocks other panes; if the pane is | ||
| 628 | - | // in a background tab it becomes visible when that tab is shown. respond is | 677 | + | // in a background tab it becomes visible when that tab is shown, and it only |
| 629 | - | // invoked once with the user's answer. Returns false if the popup is unavailable. | 678 | + | // takes keyboard focus while the pane is active (otherwise when it becomes |
| 630 | - | func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string)) bool { | 679 | + | // active). respond is invoked once with the user's answer. Returns false if the |
| 680 | + | // popup is unavailable. | |||
| 681 | + | func (pv *PaneView) ShowJSDialog(req port.JSDialogRequest, respond func(ok bool, input string), uiScale float64) bool { | |||
| 631 | pv.mu.Lock() | 682 | pv.mu.Lock() | ||
| 632 | popup := pv.ensureJSDialog() | 683 | popup := pv.ensureJSDialog() | ||
| 684 | + | focus := pv.isActive | |||
| 633 | pv.mu.Unlock() | 685 | pv.mu.Unlock() | ||
| 634 | if popup == nil { | 686 | if popup == nil { | ||
| 635 | return false | 687 | return false | ||
| 636 | } | 688 | } | ||
| 637 | - | popup.Show(req, respond) | 689 | + | popup.Show(req, respond, focus, uiScale) |
| 638 | return true | 690 | return true | ||
| 639 | } | 691 | } | ||
| 640 | 692 | ||||
M internal/ui/coordinator/content/callbacks.go +14 -14
| @@ -296,7 +296,7 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po | |||
| 296 | 296 | if pv == nil { | |
| 297 | 297 | return false | |
| 298 | 298 | } | |
| 299 | - | return pv.ShowJSDialog(req, respond) | |
| 299 | + | return pv.ShowJSDialog(req, respond, c.jsDialogUIScale()) | |
| 300 | 300 | } | |
| 301 | 301 | callbacks.OnJSDialogReset = func() { | |
| 302 | 302 | if pv := c.paneViewForJSDialog(paneID); pv != nil { | |
| @@ -305,20 +305,20 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po | |||
| 305 | 305 | } | |
| 306 | 306 | } | |
| 307 | 307 | ||
| 308 | - | // paneViewForJSDialog resolves the PaneView that owns paneID's WebView. The | |
| 309 | - | // dialog is shown inside that pane's overlay even if the pane is in a | |
| 310 | - | // background tab (it appears when the tab is shown) and never blocks others. | |
| 311 | - | func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView { | |
| 312 | - | if c.paneViewResolver != nil { | |
| 313 | - | if pv := c.paneViewResolver(paneID); pv != nil { | |
| 314 | - | return pv | |
| 315 | - | } | |
| 308 | + | // jsDialogUIScale returns the current UI scale (1 when no provider is set). | |
| 309 | + | func (c *Coordinator) jsDialogUIScale() float64 { | |
| 310 | + | if c.uiScaleProvider == nil { | |
| 311 | + | return 1 | |
| 316 | 312 | } | |
| 317 | - | if c.getActiveWS == nil { | |
| 313 | + | return c.uiScaleProvider() | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // paneViewForJSDialog resolves the PaneView that owns paneID's WebView through | |
| 317 | + | // the app-provided resolver (any tab/window). The dialog is shown inside that | |
| 318 | + | // pane's overlay even if the pane is in a background tab and never blocks others. | |
| 319 | + | func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView { | |
| 320 | + | if c.paneViewResolver == nil { | |
| 318 | 321 | return nil | |
| 319 | 322 | } | |
| 320 | - | if _, wsView := c.getActiveWS(); wsView != nil { | |
| 321 | - | return wsView.GetPaneView(paneID) | |
| 322 | - | } | |
| 323 | - | return nil | |
| 323 | + | return c.paneViewResolver(paneID) | |
| 324 | 324 | } | |
| @@ -296,7 +296,7 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po | |||||
| 296 | if pv == nil { | 296 | if pv == nil { | ||
| 297 | return false | 297 | return false | ||
| 298 | } | 298 | } | ||
| 299 | - | return pv.ShowJSDialog(req, respond) | 299 | + | return pv.ShowJSDialog(req, respond, c.jsDialogUIScale()) |
| 300 | } | 300 | } | ||
| 301 | callbacks.OnJSDialogReset = func() { | 301 | callbacks.OnJSDialogReset = func() { | ||
| 302 | if pv := c.paneViewForJSDialog(paneID); pv != nil { | 302 | if pv := c.paneViewForJSDialog(paneID); pv != nil { | ||
| @@ -305,20 +305,20 @@ func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *po | |||||
| 305 | } | 305 | } | ||
| 306 | } | 306 | } | ||
| 307 | 307 | ||||
| 308 | - | // paneViewForJSDialog resolves the PaneView that owns paneID's WebView. The | 308 | + | // jsDialogUIScale returns the current UI scale (1 when no provider is set). |
| 309 | - | // dialog is shown inside that pane's overlay even if the pane is in a | 309 | + | func (c *Coordinator) jsDialogUIScale() float64 { |
| 310 | - | // background tab (it appears when the tab is shown) and never blocks others. | 310 | + | if c.uiScaleProvider == nil { |
| 311 | - | func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView { | 311 | + | return 1 |
| 312 | - | if c.paneViewResolver != nil { | |||
| 313 | - | if pv := c.paneViewResolver(paneID); pv != nil { | |||
| 314 | - | return pv | |||
| 315 | - | } | |||
| 316 | } | 312 | } | ||
| 317 | - | if c.getActiveWS == nil { | 313 | + | return c.uiScaleProvider() |
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // paneViewForJSDialog resolves the PaneView that owns paneID's WebView through | |||
| 317 | + | // the app-provided resolver (any tab/window). The dialog is shown inside that | |||
| 318 | + | // pane's overlay even if the pane is in a background tab and never blocks others. | |||
| 319 | + | func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView { | |||
| 320 | + | if c.paneViewResolver == nil { | |||
| 318 | return nil | 321 | return nil | ||
| 319 | } | 322 | } | ||
| 320 | - | if _, wsView := c.getActiveWS(); wsView != nil { | 323 | + | return c.paneViewResolver(paneID) |
| 321 | - | return wsView.GetPaneView(paneID) | |||
| 322 | - | } | |||
| 323 | - | return nil | |||
| 324 | } | 324 | } | ||
M internal/ui/coordinator/content/coordinator.go +8 -1
| @@ -47,8 +47,10 @@ type Coordinator struct { | |||
| 47 | 47 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | |
| 48 | 48 | ||
| 49 | 49 | // Resolves a pane's view across all tabs/windows (JS dialogs can come from | |
| 50 | - | // background panes). Falls back to the active workspace view when nil. | |
| 50 | + | // background panes). JS dialogs are not shown while it is nil. | |
| 51 | 51 | paneViewResolver func(paneID entity.PaneID) *component.PaneView | |
| 52 | + | // Current UI scale, used to size JS dialogs. | |
| 53 | + | uiScaleProvider func() float64 | |
| 52 | 54 | ||
| 53 | 55 | // Callback when title changes (for history persistence) | |
| 54 | 56 | onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string) | |
| @@ -255,6 +257,11 @@ func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *compone | |||
| 255 | 257 | c.paneViewResolver = fn | |
| 256 | 258 | } | |
| 257 | 259 | ||
| 260 | + | // SetUIScaleProvider sets the source of the current UI scale. | |
| 261 | + | func (c *Coordinator) SetUIScaleProvider(fn func() float64) { | |
| 262 | + | c.uiScaleProvider = fn | |
| 263 | + | } | |
| 264 | + | ||
| 258 | 265 | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | |
| 259 | 266 | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | |
| 260 | 267 | c.onEditableFocusChanged = fn | |
| @@ -47,8 +47,10 @@ type Coordinator struct { | |||||
| 47 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | 47 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | ||
| 48 | 48 | ||||
| 49 | // Resolves a pane's view across all tabs/windows (JS dialogs can come from | 49 | // Resolves a pane's view across all tabs/windows (JS dialogs can come from | ||
| 50 | - | // background panes). Falls back to the active workspace view when nil. | 50 | + | // background panes). JS dialogs are not shown while it is nil. |
| 51 | paneViewResolver func(paneID entity.PaneID) *component.PaneView | 51 | paneViewResolver func(paneID entity.PaneID) *component.PaneView | ||
| 52 | + | // Current UI scale, used to size JS dialogs. | |||
| 53 | + | uiScaleProvider func() float64 | |||
| 52 | 54 | ||||
| 53 | // Callback when title changes (for history persistence) | 55 | // Callback when title changes (for history persistence) | ||
| 54 | onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string) | 56 | onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string) | ||
| @@ -255,6 +257,11 @@ func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *compone | |||||
| 255 | c.paneViewResolver = fn | 257 | c.paneViewResolver = fn | ||
| 256 | } | 258 | } | ||
| 257 | 259 | ||||
| 260 | + | // SetUIScaleProvider sets the source of the current UI scale. | |||
| 261 | + | func (c *Coordinator) SetUIScaleProvider(fn func() float64) { | |||
| 262 | + | c.uiScaleProvider = fn | |||
| 263 | + | } | |||
| 264 | + | ||||
| 258 | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | 265 | // SetOnEditableFocusChanged sets the callback for page editable focus changes. | ||
| 259 | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | 266 | func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { | ||
| 260 | c.onEditableFocusChanged = fn | 267 | c.onEditableFocusChanged = fn | ||