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/jwijenbergh/puregotk/v4/gdk"
10 "github.com/jwijenbergh/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 []interface{}
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// The callback receives (allowed, persistent) when the user makes a choice.
69func (pp *PermissionPopup) Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) {
70 log := logging.FromContext(ctx)
71
72 pp.mu.Lock()
73 if pp.visible {
74 pp.mu.Unlock()
75 log.Warn().Msg("permission popup already visible, ignoring Show")
76 return
77 }
78 pp.visible = true
79 pp.callback = callback
80 pp.mu.Unlock()
81
82 if pp.headingLabel != nil {
83 pp.headingLabel.SetText(heading)
84 }
85 if pp.bodyLabel != nil {
86 pp.bodyLabel.SetText(body)
87 }
88
89 pp.resizeAndCenter()
90 if pp.outerBox != nil {
91 pp.outerBox.SetVisible(true)
92 }
93 // Focus the Deny button as the conservative default
94 if pp.btnDeny != nil {
95 pp.btnDeny.GrabFocus()
96 }
97}
98
99// Hide hides the popup without invoking the callback.
100func (pp *PermissionPopup) Hide() {
101 pp.mu.Lock()
102 if !pp.visible {
103 pp.mu.Unlock()
104 return
105 }
106 pp.visible = false
107 pp.callback = nil
108 pp.mu.Unlock()
109
110 if pp.outerBox != nil {
111 pp.outerBox.SetVisible(false)
112 }
113}
114
115// IsVisible returns whether the popup is currently displayed.
116func (pp *PermissionPopup) IsVisible() bool {
117 pp.mu.Lock()
118 defer pp.mu.Unlock()
119 return pp.visible
120}
121
122func (pp *PermissionPopup) dismiss(allowed, persistent bool) {
123 pp.mu.Lock()
124 if !pp.visible {
125 pp.mu.Unlock()
126 return
127 }
128 pp.visible = false
129 cb := pp.callback
130 pp.callback = nil
131 pp.mu.Unlock()
132
133 if pp.outerBox != nil {
134 pp.outerBox.SetVisible(false)
135 }
136 if cb != nil {
137 cb(allowed, persistent)
138 }
139}
140
141// setupContainers creates and configures the outer and main boxes.
142func (pp *PermissionPopup) setupContainers() error {
143 pp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
144 if pp.outerBox == nil {
145 return errNilWidget("permissionPopupOuterBox")
146 }
147 pp.outerBox.AddCssClass("permission-popup-outer")
148 pp.outerBox.SetHalign(gtk.AlignCenterValue)
149 pp.outerBox.SetValign(gtk.AlignStartValue)
150 pp.outerBox.SetVisible(false)
151
152 pp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
153 if pp.mainBox == nil {
154 return errNilWidget("permissionPopupMainBox")
155 }
156 pp.mainBox.AddCssClass("permission-popup-container")
157 return nil
158}
159
160// setupLabels creates and configures the heading and body labels.
161func (pp *PermissionPopup) setupLabels() error {
162 emptyText := ""
163 pp.headingLabel = gtk.NewLabel(&emptyText)
164 if pp.headingLabel == nil {
165 return errNilWidget("permissionPopupHeadingLabel")
166 }
167 pp.headingLabel.AddCssClass("permission-popup-heading")
168 pp.headingLabel.SetHalign(gtk.AlignStartValue)
169
170 pp.bodyLabel = gtk.NewLabel(&emptyText)
171 if pp.bodyLabel == nil {
172 return errNilWidget("permissionPopupBodyLabel")
173 }
174 pp.bodyLabel.AddCssClass("permission-popup-body")
175 pp.bodyLabel.SetHalign(gtk.AlignStartValue)
176 pp.bodyLabel.SetWrap(true)
177 return nil
178}
179
180func (pp *PermissionPopup) createWidgets() error {
181 if err := pp.setupContainers(); err != nil {
182 return err
183 }
184
185 if err := pp.setupLabels(); err != nil {
186 return err
187 }
188
189 // Button row
190 btnRow := gtk.NewBox(gtk.OrientationHorizontalValue, buttonSpacing)
191 if btnRow == nil {
192 return errNilWidget("permissionPopupBtnRow")
193 }
194 btnRow.AddCssClass("permission-popup-btn-row")
195 btnRow.SetHalign(gtk.AlignEndValue)
196
197 // Create buttons: Always Deny | Deny | Allow | Always Allow
198 var err error
199 pp.btnAlwaysDeny, err = pp.createPermissionButton("Always Deny", []string{"permission-popup-btn", "permission-popup-btn-destructive"})
200 if err != nil {
201 return err
202 }
203
204 pp.btnDeny, err = pp.createPermissionButton("Deny", []string{"permission-popup-btn", "permission-popup-btn-deny"})
205 if err != nil {
206 return err
207 }
208
209 pp.btnAllow, err = pp.createPermissionButton("Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
210 if err != nil {
211 return err
212 }
213
214 pp.btnAlwaysAllow, err = pp.createPermissionButton("Always Allow", []string{"permission-popup-btn", "permission-popup-btn-allow"})
215 if err != nil {
216 return err
217 }
218
219 // Wire button clicks
220 pp.wireButton(pp.btnAlwaysDeny, false, true)
221 pp.wireButton(pp.btnDeny, false, false)
222 pp.wireButton(pp.btnAllow, true, false)
223 pp.wireButton(pp.btnAlwaysAllow, true, true)
224
225 // Assemble button row
226 btnRow.Append(&pp.btnAlwaysDeny.Widget)
227 btnRow.Append(&pp.btnDeny.Widget)
228 btnRow.Append(&pp.btnAllow.Widget)
229 btnRow.Append(&pp.btnAlwaysAllow.Widget)
230
231 // Assemble main box
232 pp.mainBox.Append(&pp.headingLabel.Widget)
233 pp.mainBox.Append(&pp.bodyLabel.Widget)
234 pp.mainBox.Append(&btnRow.Widget)
235
236 // Assemble outer box
237 pp.outerBox.Append(&pp.mainBox.Widget)
238
239 return nil
240}
241
242// createPermissionButton creates a permission button with the given label and CSS classes.
243func (pp *PermissionPopup) createPermissionButton(label string, cssClasses []string) (*gtk.Button, error) {
244 btn := gtk.NewButtonWithLabel(label)
245 if btn == nil {
246 return nil, errNilWidget("permissionPopupBtn" + label)
247 }
248 for _, class := range cssClasses {
249 btn.AddCssClass(class)
250 }
251 return btn, nil
252}
253
254// wireButton connects a button click to the dismiss callback.
255func (pp *PermissionPopup) wireButton(btn *gtk.Button, allowed, persistent bool) {
256 cb := func(_ gtk.Button) { pp.dismiss(allowed, persistent) }
257 pp.retainedCallbacks = append(pp.retainedCallbacks, cb)
258 btn.ConnectClicked(&cb)
259}
260
261func (pp *PermissionPopup) attachKeyController() {
262 if pp.outerBox == nil {
263 return
264 }
265 controller := gtk.NewEventControllerKey()
266 if controller == nil {
267 return
268 }
269 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
270
271 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
272 if keyval == uint(gdk.KEY_Escape) {
273 // Escape = deny (conservative default)
274 pp.dismiss(false, false)
275 return true
276 }
277 return false
278 }
279 pp.retainedCallbacks = append(pp.retainedCallbacks, keyPressedCb)
280 controller.ConnectKeyPressed(&keyPressedCb)
281 pp.outerBox.AddController(&controller.EventController)
282}
283
284func (pp *PermissionPopup) resizeAndCenter() {
285 if pp.outerBox == nil || pp.mainBox == nil {
286 return
287 }
288
289 width, marginTop := CalculateModalDimensions(pp.parentOverlay, PermissionPopupSizeDefaults)
290 pp.mainBox.SetSizeRequest(width, -1)
291 pp.outerBox.SetMarginTop(marginTop)
292}