1package component
2
3import (
4 "context"
5 "sync"
6
7 "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/dumber/internal/ui/layout"
9 "github.com/bnema/puregotk/v4/gdk"
10 "github.com/bnema/puregotk/v4/gtk"
11)
12
13// buttonSpacing is the spacing between buttons in the permission popup.
14const buttonSpacing = 6
15
16// PermissionPopup is a custom overlay component for permission prompts.
17// It replaces the Adwaita AlertDialog to sidestep the purego ConnectResponse bug
18// and match the app's custom UI style.
19type PermissionPopup struct {
20 outerBox *gtk.Box
21 mainBox *gtk.Box
22
23 headingLabel *gtk.Label
24 bodyLabel *gtk.Label
25
26 btnAllow *gtk.Button
27 btnAlwaysAllow *gtk.Button
28 btnDeny *gtk.Button
29 btnAlwaysDeny *gtk.Button
30
31 parentOverlay layout.OverlayWidget
32 uiScale float64
33
34 mu sync.Mutex
35 visible bool
36 callback func(allowed, persistent bool)
37
38 retainedCallbacks []any
39}
40
41// NewPermissionPopup creates a new permission popup component.
42func NewPermissionPopup(parentOverlay layout.OverlayWidget, uiScale float64) *PermissionPopup {
43 if uiScale <= 0 {
44 uiScale = 1.0
45 }
46
47 pp := &PermissionPopup{
48 parentOverlay: parentOverlay,
49 uiScale: uiScale,
50 }
51
52 if err := pp.createWidgets(); err != nil {
53 return nil
54 }
55 pp.attachKeyController()
56 return pp
57}
58
59// Widget returns the outer GTK widget for overlay registration.
60func (pp *PermissionPopup) Widget() *gtk.Widget {
61 if pp.outerBox == nil {
62 return nil
63 }
64 return &pp.outerBox.Widget
65}
66
67// Show displays the permission popup with the given heading and body text.
68// When persistable is false the "Always Allow/Deny" buttons are hidden.
69// The callback receives (allowed, persistent) when the user makes a choice.
70func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, persistable bool, callback func(allowed, persistent bool)) {
71 log := logging.FromContext(ctx)
72
73 pp.mu.Lock()
74 if pp.visible {
75 pp.mu.Unlock()
76 log.Warn().Msg("permission popup already visible, ignoring Show")
77 return
78 }
79 pp.visible = true
80 pp.callback = callback
81 pp.mu.Unlock()
82
83 if pp.headingLabel != nil {
84 pp.headingLabel.SetText(heading)
85 }
86 if pp.bodyLabel != nil {
87 pp.bodyLabel.SetText(body)
88 }
89 if pp.btnAlwaysAllow != nil {
90 pp.btnAlwaysAllow.SetVisible(persistable)
91 }
92 if pp.btnAlwaysDeny != nil {
93 pp.btnAlwaysDeny.SetVisible(persistable)
94 }
95
96 pp.resizeAndCenter()
97 if pp.outerBox != nil {
98 pp.outerBox.SetVisible(true)
99 }
100 // Focus the Deny button as the conservative default
101 if pp.btnDeny != nil {
102 pp.btnDeny.GrabFocus()
103 }
104}
105
106// Hide hides the popup without invoking the callback.
107func (pp *PermissionPopup) Hide() {
108 pp.mu.Lock()
109 if !pp.visible {
110 pp.mu.Unlock()
111 return
112 }
113 pp.visible = false
114 pp.callback = nil
115 pp.mu.Unlock()
116
117 if pp.outerBox != nil {
118 pp.outerBox.SetVisible(false)
119 }
120}
121
122// IsVisible returns whether the popup is currently displayed.
123func (pp *PermissionPopup) IsVisible() bool {
124 pp.mu.Lock()
125 defer pp.mu.Unlock()
126 return pp.visible
127}
128
129func (pp *PermissionPopup) dismiss(allowed, persistent bool) {
130 pp.mu.Lock()
131 if !pp.visible {
132 pp.mu.Unlock()
133 return
134 }
135 pp.visible = false
136 cb := pp.callback
137 pp.callback = nil
138 pp.mu.Unlock()
139
140 if pp.outerBox != nil {
141 pp.outerBox.SetVisible(false)
142 }
143 if cb != nil {
144 cb(allowed, persistent)
145 }
146}
147
148// setupContainers creates and configures the outer and main boxes.
149func (pp *PermissionPopup) setupContainers() error {
150 pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
151 if pp.outerBox == nil {
152 return errNilWidget("permissionPopupOuterBox")
153 }
154 pp.outerBox.AddCssClass("permission-popup-outer")
155 pp.outerBox.SetHalign(gtk.AlignCenterValue)
156 pp.outerBox.SetValign(gtk.AlignStartValue)
157 pp.outerBox.SetVisible(false)
158
159 pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
160 if pp.mainBox == nil {
161 return errNilWidget("permissionPopupMainBox")
162 }
163 pp.mainBox.AddCssClass("permission-popup-container")
164 return nil
165}
166
167// setupLabels creates and configures the heading and body labels.
168func (pp *PermissionPopup) setupLabels() error {
169 emptyText := ""
170 pp.headingLabel = gtk.NewLabel(&emptyText)
171 if pp.headingLabel == nil {
172 return errNilWidget("permissionPopupHeadingLabel")
173 }
174 pp.headingLabel.AddCssClass("permission-popup-heading")
175 pp.headingLabel.SetHalign(gtk.AlignStartValue)
176
177 pp.bodyLabel = gtk.NewLabel(&emptyText)
178 if pp.bodyLabel == nil {
179 return errNilWidget("permissionPopupBodyLabel")
180 }
181 pp.bodyLabel.AddCssClass("permission-popup-body")
182 pp.bodyLabel.SetHalign(gtk.AlignStartValue)
183 pp.bodyLabel.SetWrap(true)
184 return nil
185}
186
187func (pp *PermissionPopup) createWidgets() error {
188 if err := pp.setupContainers(); err != nil {
189 return err
190 }
191
192 if err := pp.setupLabels(); err != nil {
193 return err
194 }
195
196 // Button row
197 btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
198 if btnRow == nil {
199 return errNilWidget("permissionPopupBtnRow")
200 }
201 btnRow.AddCssClass("permission-popup-btn-row")
202 btnRow.SetHalign(gtk.AlignEndValue)
203
204 // Create buttons: Always Deny | Deny | Allow | Always Allow
205 var err error
206 pp.btnAlwaysDeny, err = pp.createPermissionButton("Always Deny", []string{"permission-popup-btn", "permission-popup-btn-destructive"})
207 if err != nil {
208 return err
209 }
210
211 pp.btnDeny, err = pp.createPermissionButton("Deny", []string{"permission-popup-btn", "permission-popup-btn-deny"})
212 if err != nil {
213 return err
214 }
215
216 pp.btnAllow, err = pp.createPermissionButton("Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
217 if err != nil {
218 return err
219 }
220
221 pp.btnAlwaysAllow, err = pp.createPermissionButton("Always Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
222 if err != nil {
223 return err
224 }
225
226 // Wire button clicks
227 pp.wireButton(pp.btnAlwaysDeny, false, true)
228 pp.wireButton(pp.btnDeny, false, false)
229 pp.wireButton(pp.btnAllow, true, false)
230 pp.wireButton(pp.btnAlwaysAllow, true, true)
231
232 // Assemble button row
233 btnRow.Append(&pp.btnAlwaysDeny.Widget)
234 btnRow.Append(&pp.btnDeny.Widget)
235 btnRow.Append(&pp.btnAllow.Widget)
236 btnRow.Append(&pp.btnAlwaysAllow.Widget)
237
238 // Assemble main box
239 pp.mainBox.Append(&pp.headingLabel.Widget)
240 pp.mainBox.Append(&pp.bodyLabel.Widget)
241 pp.mainBox.Append(&btnRow.Widget)
242
243 // Assemble outer box
244 pp.outerBox.Append(&pp.mainBox.Widget)
245
246 return nil
247}
248
249// createPermissionButton creates a permission button with the given label and CSS classes.
250func (pp *PermissionPopup) createPermissionButton(label string, cssClasses []string) (*gtk.Button, error) {
251 btn := gtk.NewButtonWithLabel(label)
252 if btn == nil {
253 return nil, errNilWidget("permissionPopupBtn" + label)
254 }
255 for _, class := range cssClasses {
256 btn.AddCssClass(class)
257 }
258 return btn, nil
259}
260
261// wireButton connects a button click to the dismiss callback.
262func (pp *PermissionPopup) wireButton(btn *gtk.Button, allowed, persistent bool) {
263 cb := func(_ gtk.Button) { pp.dismiss(allowed, persistent) }
264 pp.retainedCallbacks = append(pp.retainedCallbacks, cb)
265 btn.ConnectClicked(&cb)
266}
267
268func (pp *PermissionPopup) attachKeyController() {
269 if pp.outerBox == nil {
270 return
271 }
272 controller := gtk.NewEventControllerKey()
273 if controller == nil {
274 return
275 }
276 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
277
278 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
279 if keyval == uint(gdk.KEY_Escape) {
280 // Escape = deny (conservative default)
281 pp.dismiss(false, false)
282 return true
283 }
284 return false
285 }
286 pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb)
287 controller.ConnectKeyPressed(&keyPressedCb)
288 pp.outerBox.AddController(&controller.EventController)
289}
290
291func (pp *PermissionPopup) resizeAndCenter() {
292 if pp.outerBox == nil || pp.mainBox == nil {
293 return
294 }
295
296 width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults)
297 pp.mainBox.SetSizeRequest(width, -1)
298 pp.outerBox.SetMarginTop(marginTop)
299}