From 6978190ee6f6723177d5ab37a7155884f1d35386 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Sat, 21 Mar 2026 09:02:37 -0400 Subject: [PATCH 1/6] Add PopupDialog widget - draggable, re-focusable dialog. - baseDialog.js: adds a base class for shared code between modal and popup dialog. - popupDialog.js: Draggable wrapper for Dialog.Dialog, to use in place of ModalDialog. - The 'popup-dialog' css class can be used to help differeniation. In the default theme, there's a faint box shadow that uses the accent color. ref: https://github.com/orgs/linuxmint/discussions/774 --- .../theme/cinnamon-sass/widgets/_dialogs.scss | 7 + js/ui/baseDialog.js | 134 +++++++++++ js/ui/dialog.js | 2 +- js/ui/modalDialog.js | 170 +++----------- js/ui/popupDialog.js | 210 ++++++++++++++++++ src/cinnamon-global.h | 3 +- 6 files changed, 382 insertions(+), 144 deletions(-) create mode 100644 js/ui/baseDialog.js create mode 100644 js/ui/popupDialog.js diff --git a/data/theme/cinnamon-sass/widgets/_dialogs.scss b/data/theme/cinnamon-sass/widgets/_dialogs.scss index 3f871e54ed..eeebeb1357 100644 --- a/data/theme/cinnamon-sass/widgets/_dialogs.scss +++ b/data/theme/cinnamon-sass/widgets/_dialogs.scss @@ -194,3 +194,10 @@ } } } + +// Popup dialog (non-modal, draggable) +.popup-dialog { + .dialog { + box-shadow: 0 0 3px 2px transparentize($accent_bg_color, 0.6); + } +} diff --git a/js/ui/baseDialog.js b/js/ui/baseDialog.js new file mode 100644 index 0000000000..921e74b804 --- /dev/null +++ b/js/ui/baseDialog.js @@ -0,0 +1,134 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Clutter = imports.gi.Clutter; +const St = imports.gi.St; +const GObject = imports.gi.GObject; + +const Params = imports.misc.params; + +var State = { + OPENED: 0, + CLOSED: 1, + OPENING: 2, + CLOSING: 3, + FADED_OUT: 4 +}; + +var BaseDialog = GObject.registerClass({ + Properties: { + 'state': GObject.ParamSpec.int( + 'state', 'Dialog state', 'state', + GObject.ParamFlags.READABLE, + Math.min(...Object.values(State)), + Math.max(...Object.values(State)), + State.CLOSED) + }, + Signals: { 'opened': {}, 'closed': {} } +}, class BaseDialog extends St.Widget { + + _init(stWidgetProps, params) { + super._init(stWidgetProps); + + params = Params.parse(params, { + destroyOnClose: true, + }); + + this._state = State.CLOSED; + this._destroyOnClose = params.destroyOnClose; + + this.openAndCloseTime = 100; + if (!global.settings.get_boolean("desktop-effects-workspace")) { + this.openAndCloseTime = 0; + } + + this._initialKeyFocus = null; + this._initialKeyFocusDestroyId = 0; + } + + _initDialogLayout(dialogLayout) { + this.dialogLayout = dialogLayout; + this.contentLayout = dialogLayout.contentLayout; + this.buttonLayout = dialogLayout.buttonLayout; + global.focus_manager.add_group(dialogLayout); + } + + get state() { + return this._state; + } + + _setState(state) { + if (this._state == state) + return; + + this._state = state; + this.notify('state'); + } + + clearButtons() { + this.dialogLayout.clearButtons(); + } + + setButtons(buttons) { + this.clearButtons(); + + for (let buttonInfo of buttons) { + this.addButton(buttonInfo); + } + } + + addButton(buttonInfo) { + return this.dialogLayout.addButton(buttonInfo); + } + + setInitialKeyFocus(actor) { + if (this._initialKeyFocusDestroyId) + this._initialKeyFocus.disconnect(this._initialKeyFocusDestroyId); + + this._initialKeyFocus = actor; + + this._initialKeyFocusDestroyId = actor.connect('destroy', () => { + this._initialKeyFocus = null; + this._initialKeyFocusDestroyId = 0; + }); + } + + _grabInitialKeyFocus() { + let focus = this._initialKeyFocus || this.dialogLayout.initialKeyFocus; + focus.grab_key_focus(); + } + + _animateOpen() { + this._setState(State.OPENING); + + this.dialogLayout.opacity = 255; + this.opacity = 0; + this.show(); + this.ease({ + opacity: 255, + duration: this.openAndCloseTime, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._setState(State.OPENED); + this.emit('opened'); + } + }); + } + + _animateClose() { + this._setState(State.CLOSING); + + this.ease({ + opacity: 0, + duration: this.openAndCloseTime, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._setState(State.CLOSED); + this.hide(); + this.emit('closed'); + + if (this._destroyOnClose) + this.destroy(); + } + }); + } +}); diff --git a/js/ui/dialog.js b/js/ui/dialog.js index d96b3ebcaa..5a7baca246 100644 --- a/js/ui/dialog.js +++ b/js/ui/dialog.js @@ -45,7 +45,7 @@ class Dialog extends St.Widget { vertical: true }); - // modal dialogs are fixed width and grow vertically; set the request + // Dialogs are fixed width and grow vertically; set the request // mode accordingly so wrapped labels are handled correctly during // size requests. this._dialog.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); diff --git a/js/ui/modalDialog.js b/js/ui/modalDialog.js index 6d34a4761d..13801b5daa 100644 --- a/js/ui/modalDialog.js +++ b/js/ui/modalDialog.js @@ -15,6 +15,7 @@ const Gdk = imports.gi.Gdk; const Params = imports.misc.params; const Util = imports.misc.util; +const BaseDialog = imports.ui.baseDialog; const Dialog = imports.ui.dialog; const Layout = imports.ui.layout; const Lightbox = imports.ui.lightbox; @@ -24,13 +25,7 @@ const Gettext = imports.gettext; const FADE_OUT_DIALOG_TIME = 1000; -var State = { - OPENED: 0, - CLOSED: 1, - OPENING: 2, - CLOSING: 3, - FADED_OUT: 4 -}; +var State = BaseDialog.State; /** * #ModalDialog: @@ -47,17 +42,8 @@ var State = { * For simple usage such as displaying a message, or asking for confirmation, * the #ConfirmDialog and #NotifyDialog classes may be used instead. */ -var ModalDialog = GObject.registerClass({ - Properties: { - 'state': GObject.ParamSpec.int( - 'state', 'Dialog state', 'state', - GObject.ParamFlags.READABLE, - Math.min(...Object.values(State)), - Math.max(...Object.values(State)), - State.CLOSED) - }, - Signals: { 'opened': {}, 'closed': {} } -}, class ModalDialog extends St.Widget { +var ModalDialog = GObject.registerClass( +class ModalDialog extends BaseDialog.BaseDialog { /** * _init: * @params (JSON): parameters for the modal dialog. Options include @@ -66,22 +52,24 @@ var ModalDialog = GObject.registerClass({ * modal dialog should use. */ _init(params) { + params = Params.parse(params, { + cinnamonReactive: Main.virtualKeyboardManager.enabled, + styleClass: null, + destroyOnClose: true, + }); + super._init({ visible: false, x: 0, y: 0, accessible_role: Atk.Role.DIALOG, - }); - params = Params.parse(params, { - cinnamonReactive: Main.virtualKeyboardManager.enabled, - styleClass: null, - destroyOnClose: true, + }, { + destroyOnClose: params.destroyOnClose, }); - this._state = State.CLOSED; this._hasModal = false; + this._savedKeyFocus = null; this._cinnamonReactive = params.cinnamonReactive; - this._destroyOnClose = params.destroyOnClose; Main.uiGroup.add_actor(this); @@ -102,13 +90,10 @@ var ModalDialog = GObject.registerClass({ this.add_actor(this._backgroundBin); this.dialogLayout = new Dialog.Dialog(this.backgroundStack, params.styleClass); - this.contentLayout = this.dialogLayout.contentLayout; - this.buttonLayout = this.dialogLayout.buttonLayout; + this._initDialogLayout(this.dialogLayout); - this.openAndCloseTime = 100; let enableRadialEffect = true; if (!global.settings.get_boolean("desktop-effects-workspace")) { - this.openAndCloseTime = 0; enableRadialEffect = false; } @@ -121,106 +106,15 @@ var ModalDialog = GObject.registerClass({ this._eventBlocker = new Clutter.Actor({ reactive: true }); this.backgroundStack.add_actor(this._eventBlocker); } - - global.focus_manager.add_group(this.dialogLayout); - this._initialKeyFocus = null; - this._initialKeyFocusDestroyId = 0; - this._savedKeyFocus = null; - } - - get state() { - return this._state; - } - - _setState(state) { - if (this._state == state) - return; - - this._state = state; - this.notify('state'); - } - - clearButtons() { - this.dialogLayout.clearButtons(); - } - - /** - * setButtons: - * @buttons (array): the buttons to display in the modal dialog - * - * This sets the buttons in the modal dialog. The buttons is an array of - * JSON objects, each of which corresponds to one button. - * - * Each JSON object *must* contain @label and @action, which are the text - * displayed on the button and the callback function to use when the button - * is clicked respectively. - * - * Optional arguments include @focused, which determines whether the button - * is initially focused, and @key, which is a keybinding associated with - * the button press such that pressing the keybinding will have the same - * effect as clicking the button. Other arguments include @default - * and @destructive_action which add additional styling. - * - * An example usage is - * ``` - * dialog.setButtons([ - * { - * label: _("Cancel"), - * action: this.callback.bind(this), - * key: Clutter.KEY_Escape - * }, - * { - * label: _("OK"), - * action: this.destroy.bind(this), - * key: Clutter.KEY_Return, - * default: true - * } - * ]); - * ``` - */ - setButtons(buttons) { - this.clearButtons(); - - for (let buttonInfo of buttons) { - this.addButton(buttonInfo); - } - } - - addButton(buttonInfo) { - return this.dialogLayout.addButton(buttonInfo); } _fadeOpen() { this._monitorConstraint.index = global.display.get_current_monitor(); - this._setState(State.OPENING); - - this.dialogLayout.opacity = 255; if (this._lightbox) this._lightbox.lightOn(); - this.opacity = 0; - this.show(); - this.ease({ - opacity: 255, - duration: this.openAndCloseTime, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - onComplete: () => { - this._setState(State.OPENED); - this.emit('opened'); - } - }); - } - - setInitialKeyFocus(actor) { - if (this._initialKeyFocusDestroyId) - this._initialKeyFocus.disconnect(this._initialKeyFocusDestroyId); - this._initialKeyFocus = actor; - - this._initialKeyFocusDestroyId = actor.connect('destroy', () => { - this._initialKeyFocus = null; - this._initialKeyFocusDestroyId = 0; - }); + this._animateOpen(); } /** @@ -238,6 +132,18 @@ var ModalDialog = GObject.registerClass({ return false; this._fadeOpen(); + + let openedId = this.connect('opened', () => { + this.disconnect(openedId); + + if (this._savedKeyFocus) { + this._savedKeyFocus.grab_key_focus(); + this._savedKeyFocus = null; + } else { + this._grabInitialKeyFocus(); + } + }); + return true; } @@ -252,23 +158,10 @@ var ModalDialog = GObject.registerClass({ if (this.state == State.CLOSED || this.state == State.CLOSING) return; - this._setState(State.CLOSING); this.popModal(timestamp); this._savedKeyFocus = null; - this.ease({ - opacity: 0, - duration: this.openAndCloseTime, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - onComplete: () => { - this._setState(State.CLOSED); - this.hide(); - this.emit('closed'); - - if (this._destroyOnClose) - this.destroy(); - } - }); + this._animateClose(); } /** @@ -317,13 +210,6 @@ var ModalDialog = GObject.registerClass({ return false; this._hasModal = true; - if (this._savedKeyFocus) { - this._savedKeyFocus.grab_key_focus(); - this._savedKeyFocus = null; - } else { - let focus = this._initialKeyFocus || this.dialogLayout.initialKeyFocus; - focus.grab_key_focus(); - } if (!this._cinnamonReactive) this._eventBlocker.lower_bottom(); diff --git a/js/ui/popupDialog.js b/js/ui/popupDialog.js new file mode 100644 index 0000000000..abe327d157 --- /dev/null +++ b/js/ui/popupDialog.js @@ -0,0 +1,210 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Clutter = imports.gi.Clutter; +const Cinnamon = imports.gi.Cinnamon; +const Meta = imports.gi.Meta; +const St = imports.gi.St; +const Atk = imports.gi.Atk; +const GObject = imports.gi.GObject; + +const Params = imports.misc.params; + +const BaseDialog = imports.ui.baseDialog; +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; + +var State = BaseDialog.State; + +var PopupDialog = GObject.registerClass( +class PopupDialog extends BaseDialog.BaseDialog { + + _init(params) { + params = Params.parse(params, { + styleClass: null, + destroyOnClose: true, + }); + + super._init({ + visible: false, + reactive: true, + accessible_role: Atk.Role.DIALOG, + layout_manager: new Clutter.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL + }), + style_class: 'popup-dialog', + }, { + destroyOnClose: params.destroyOnClose, + }); + + this._windowFocusChangedId = 0; + this._dragCaptureId = 0; + this._savedKeyFocus = null; + + this.dialogLayout = new Dialog.Dialog(this, params.styleClass); + this._initDialogLayout(this.dialogLayout); + + this.dialogLayout._dialog.x_align = Clutter.ActorAlign.FILL; + + Main.uiGroup.add_actor(this); + + this._setupDragging(); + } + + open() { + if (this.state == State.OPENED || this.state == State.OPENING) + return true; + + this._centerOnMonitor(); + + Main.layoutManager.trackChrome(this, { affectsInputRegion: true }); + + this._focusStage(); + this._grabInitialKeyFocus(); + + this._windowFocusChangedId = global.display.connect( + 'notify::focus-window', this._onWindowFocusChanged.bind(this) + ); + + this._animateOpen(); + return true; + } + + close() { + if (this.state == State.CLOSED || this.state == State.CLOSING) + return; + + if (this._windowFocusChangedId) { + global.display.disconnect(this._windowFocusChangedId); + this._windowFocusChangedId = 0; + } + + Main.layoutManager.untrackChrome(this); + global.set_stage_input_mode(Cinnamon.StageInputMode.NORMAL); + this._animateClose(); + } + + _centerOnMonitor() { + this.translation_x = 0; + this.translation_y = 0; + + let monitor = Main.layoutManager.monitors[ + global.display.get_current_monitor() + ]; + + this.opacity = 0; + this.show(); + + let x = monitor.x + Math.round((monitor.width - this.width) / 2); + let y = monitor.y + Math.round((monitor.height - this.height) / 2); + this.set_position(x, y); + } + + _focusStage() { + global.set_stage_input_mode(Cinnamon.StageInputMode.FOCUSED); + } + + _saveAndClearFocus() { + let focus = global.stage.key_focus; + if (focus && this.contains(focus)) + this._savedKeyFocus = focus; + + global.stage.set_key_focus(null); + } + + _restoreFocus() { + this._focusStage(); + + if (this._savedKeyFocus) { + this._savedKeyFocus.grab_key_focus(); + this._savedKeyFocus = null; + } else { + this._grabInitialKeyFocus(); + } + } + + _onWindowFocusChanged() { + if (global.display.focus_window) + this._saveAndClearFocus(); + } + + _setupDragging() { + this.connect('button-press-event', (actor, event) => { + if (event.get_button() !== 1) + return Clutter.EVENT_PROPAGATE; + + let source = event.get_source(); + if (this._isInteractiveActor(source)) { + this._restoreFocus(); + return Clutter.EVENT_PROPAGATE; + } + + this._startDrag(event); + return Clutter.EVENT_STOP; + }); + } + + _isInteractiveActor(actor) { + let boundary = this.dialogLayout._dialog; + while (actor && actor !== boundary) { + if (actor instanceof St.Button || + actor instanceof St.Entry || + actor.track_hover) + return true; + actor = actor.get_parent(); + } + return false; + } + + _startDrag(event) { + let [stageX, stageY] = event.get_coords(); + + this._dragStartX = stageX; + this._dragStartY = stageY; + this._dragOrigTransX = this.translation_x; + this._dragOrigTransY = this.translation_y; + + this._saveAndClearFocus(); + + this._dragGrabbed = global.begin_modal(global.get_current_time(), 0); + global.display.set_cursor(Meta.Cursor.GRABBING); + + this._dragCaptureId = global.stage.connect('captured-event', (stageActor, ev) => { + return this._onDragEvent(ev); + }); + } + + _onDragEvent(event) { + if (event.type() === Clutter.EventType.MOTION) { + let [stageX, stageY] = event.get_coords(); + + this.translation_x = this._dragOrigTransX + (stageX - this._dragStartX); + this.translation_y = this._dragOrigTransY + (stageY - this._dragStartY); + + return Clutter.EVENT_STOP; + } + + if (event.type() === Clutter.EventType.BUTTON_RELEASE) { + this._endDrag(); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _endDrag() { + global.display.set_cursor(Meta.Cursor.DEFAULT); + + if (this._dragCaptureId) { + global.stage.disconnect(this._dragCaptureId); + this._dragCaptureId = 0; + } + + if (this._dragGrabbed) { + global.end_modal(global.get_current_time()); + this._dragGrabbed = false; + } + + this._restoreFocus(); + Main.layoutManager.updateChrome(); + } +}); diff --git a/src/cinnamon-global.h b/src/cinnamon-global.h index 02d62d30ac..b5bc3a1e71 100644 --- a/src/cinnamon-global.h +++ b/src/cinnamon-global.h @@ -85,7 +85,8 @@ typedef enum { CINNAMON_CURSOR_RESIZE_TOP_RIGHT, CINNAMON_CURSOR_RESIZE_TOP_LEFT, CINNAMON_CURSOR_CROSSHAIR, - CINNAMON_CURSOR_TEXT + CINNAMON_CURSOR_TEXT, + CINNAMON_CURSOR_GRABBING } CinnamonCursor; void cinnamon_global_set_cursor (CinnamonGlobal *global, From 713fae2b5ebfb683238c985395cb9d17e91a1498 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Sat, 21 Mar 2026 09:04:47 -0400 Subject: [PATCH 2/6] st-entry: Switch to I-beam cursor when over the widget. This is nice to have now with dialogs not being permanently-focused. --- src/cinnamon-global.c | 15 +++++++++++++ src/st/st-entry.c | 51 +++++++++++++++++++++++++++++++++++++++++++ src/st/st-entry.h | 4 ++++ 3 files changed, 70 insertions(+) diff --git a/src/cinnamon-global.c b/src/cinnamon-global.c index 14d5daa64e..e6bf96f8ad 100644 --- a/src/cinnamon-global.c +++ b/src/cinnamon-global.c @@ -673,6 +673,9 @@ cinnamon_global_set_cursor (CinnamonGlobal *global, case CINNAMON_CURSOR_TEXT: ret_curs = META_CURSOR_TEXT; break; + case CINNAMON_CURSOR_GRABBING: + ret_curs = META_CURSOR_GRABBING; + break; default: g_return_if_reached (); } @@ -883,6 +886,17 @@ ui_scaling_factor_changed (MetaSettings *settings, } +static void +entry_cursor_func (StEntry *entry, + gboolean use_ibeam, + gpointer user_data) +{ + CinnamonGlobal *global = user_data; + + meta_display_set_cursor (global->meta_display, + use_ibeam ? META_CURSOR_TEXT : META_CURSOR_DEFAULT); +} + void _cinnamon_global_set_plugin (CinnamonGlobal *global, MetaPlugin *plugin) @@ -907,6 +921,7 @@ _cinnamon_global_set_plugin (CinnamonGlobal *global, global->stage = CLUTTER_STAGE (meta_get_stage_for_display (global->meta_display)); st_clipboard_set_selection (meta_display_get_selection (global->meta_display)); + st_entry_set_cursor_func (entry_cursor_func, global); g_signal_connect (global->stage, "notify::width", G_CALLBACK (global_stage_notify_width), global); diff --git a/src/st/st-entry.c b/src/st/st-entry.c index 49f78bade7..6c7b87c454 100644 --- a/src/st/st-entry.c +++ b/src/st/st-entry.c @@ -109,6 +109,7 @@ struct _StEntryPrivate gboolean hint_visible; gboolean capslock_warning_shown; + gboolean has_ibeam; CoglPipeline *text_shadow_material; gfloat shadow_width; @@ -878,6 +879,54 @@ st_entry_paint (ClutterActor *actor, parent_class->paint (actor, paint_context); } +static StEntryCursorFunc cursor_func = NULL; +static gpointer cursor_func_data = NULL; + +/** + * st_entry_set_cursor_func: (skip) + * + * Set a callback for changing the mouse cursor when hovering over the entry. + */ +void +st_entry_set_cursor_func (StEntryCursorFunc func, + gpointer data) +{ + cursor_func = func; + cursor_func_data = data; +} + +static void +st_entry_set_cursor (StEntry *entry, + gboolean use_ibeam) +{ + if (cursor_func) + cursor_func (entry, use_ibeam, cursor_func_data); + + ST_ENTRY_PRIV (entry)->has_ibeam = use_ibeam; +} + +static gboolean +st_entry_enter_event (ClutterActor *actor, + ClutterCrossingEvent *event) +{ + StEntryPrivate *priv = ST_ENTRY_PRIV (actor); + + if (event->source == priv->entry && + event->related != NULL) + st_entry_set_cursor (ST_ENTRY (actor), TRUE); + + return CLUTTER_ACTOR_CLASS (st_entry_parent_class)->enter_event (actor, event); +} + +static gboolean +st_entry_leave_event (ClutterActor *actor, + ClutterCrossingEvent *event) +{ + st_entry_set_cursor (ST_ENTRY (actor), FALSE); + + return CLUTTER_ACTOR_CLASS (st_entry_parent_class)->leave_event (actor, event); +} + static void st_entry_class_init (StEntryClass *klass) { @@ -896,6 +945,8 @@ st_entry_class_init (StEntryClass *klass) actor_class->paint = st_entry_paint; actor_class->captured_event = st_entry_captured_event; + actor_class->enter_event = st_entry_enter_event; + actor_class->leave_event = st_entry_leave_event; actor_class->key_press_event = st_entry_key_press_event; actor_class->key_focus_in = st_entry_key_focus_in; diff --git a/src/st/st-entry.h b/src/st/st-entry.h index bce422c3ee..179c26ce43 100644 --- a/src/st/st-entry.h +++ b/src/st/st-entry.h @@ -92,6 +92,10 @@ gdouble st_entry_get_progress (StEntry *entry); void st_entry_start_busy (StEntry *entry); void st_entry_end_busy (StEntry *entry); +typedef void (*StEntryCursorFunc) (StEntry *entry, gboolean use_ibeam, gpointer data); +void st_entry_set_cursor_func (StEntryCursorFunc func, + gpointer user_data); + G_END_DECLS #endif /* __ST_ENTRY_H__ */ From 7e707c6b3b035e20c76ce7ae6dfcc464b449c568 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Sat, 21 Mar 2026 12:09:05 -0400 Subject: [PATCH 3/6] Convert some system dialogs to PopupDialog. --- js/ui/keyringPrompt.js | 21 ++++------------- js/ui/networkAgent.js | 16 ++++++------- js/ui/polkitAuthenticationAgent.js | 37 ++++++------------------------ 3 files changed, 19 insertions(+), 55 deletions(-) diff --git a/js/ui/keyringPrompt.js b/js/ui/keyringPrompt.js index 6a4cd65863..d257f01c6a 100644 --- a/js/ui/keyringPrompt.js +++ b/js/ui/keyringPrompt.js @@ -7,13 +7,13 @@ const GObject = imports.gi.GObject; const Gcr = imports.gi.Gcr; const Dialog = imports.ui.dialog; -const ModalDialog = imports.ui.modalDialog; +const PopupDialog = imports.ui.popupDialog; const CinnamonEntry = imports.ui.cinnamonEntry; const CheckBox = imports.ui.checkBox; const Util = imports.misc.util; var KeyringDialog = GObject.registerClass( -class KeyringDialog extends ModalDialog.ModalDialog { +class KeyringDialog extends PopupDialog.PopupDialog { _init() { super._init({ styleClass: 'prompt-dialog' }); @@ -127,21 +127,8 @@ class KeyringDialog extends ModalDialog.ModalDialog { } _ensureOpen() { - // NOTE: ModalDialog.open() is safe to call if the dialog is - // already open - it just returns true without side-effects - if (this.open()) - return true; - - // The above fail if e.g. unable to get input grab - // - // In an ideal world this wouldn't happen (because - // Cinnamon is in complete control of the session) but that's - // just not how things work right now. - - log('keyringPrompt: Failed to show modal dialog.' + - ' Dismissing prompt request'); - this.prompt.cancel(); - return false; + this.open(); + return true; } _onShowPassword() { diff --git a/js/ui/networkAgent.js b/js/ui/networkAgent.js index 1645869dd4..9c96db9e93 100644 --- a/js/ui/networkAgent.js +++ b/js/ui/networkAgent.js @@ -13,13 +13,13 @@ const Signals = imports.signals; const Dialog = imports.ui.dialog; const Main = imports.ui.main; const MessageTray = imports.ui.messageTray; -const ModalDialog = imports.ui.modalDialog; +const PopupDialog = imports.ui.popupDialog; const CinnamonEntry = imports.ui.cinnamonEntry; const VPN_UI_GROUP = 'VPN Plugin UI'; var NetworkSecretDialog = GObject.registerClass( -class NetworkSecretDialog extends ModalDialog.ModalDialog { +class NetworkSecretDialog extends PopupDialog.PopupDialog { _init(agent, requestId, connection, settingName, hints, flags, contentOverride) { super._init({ styleClass: 'prompt-dialog' }); @@ -139,14 +139,14 @@ class NetworkSecretDialog extends ModalDialog.ModalDialog { if (valid) { this._agent.respond(this._requestId, Cinnamon.NetworkAgentResponse.CONFIRMED); - this.close(global.get_current_time()); + this.close(); } // do nothing if not valid } cancel() { this._agent.respond(this._requestId, Cinnamon.NetworkAgentResponse.USER_CANCELED); - this.close(global.get_current_time()); + this.close(); } _validateWpaPsk(secret) { @@ -418,7 +418,7 @@ var VPNRequestHandler = class { this._agent.respond(this._requestId, Cinnamon.NetworkAgentResponse.USER_CANCELED); if (this._newStylePlugin && this._cinnamonDialog) { - this._cinnamonDialog.close(global.get_current_time()); + this._cinnamonDialog.close(); this._cinnamonDialog.destroy(); } else { try { @@ -578,7 +578,7 @@ var VPNRequestHandler = class { if (contentOverride && contentOverride.secrets.length) { // Only show the dialog if we actually have something to ask this._cinnamonDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], this._flags, contentOverride); - this._cinnamonDialog.open(global.get_current_time()); + this._cinnamonDialog.open(); } else { this._agent.respond(this._requestId, Cinnamon.NetworkAgentResponse.CONFIRMED); this.destroy(); @@ -726,12 +726,12 @@ var NetworkAgent = class { delete this._dialogs[requestId]; }); this._dialogs[requestId] = dialog; - dialog.open(global.get_current_time()); + dialog.open(); } _cancelRequest(agent, requestId) { if (this._dialogs[requestId]) { - this._dialogs[requestId].close(global.get_current_time()); + this._dialogs[requestId].close(); this._dialogs[requestId].destroy(); delete this._dialogs[requestId]; } else if (this._vpnRequests[requestId]) { diff --git a/js/ui/polkitAuthenticationAgent.js b/js/ui/polkitAuthenticationAgent.js index d3875ed372..e88a89ee79 100644 --- a/js/ui/polkitAuthenticationAgent.js +++ b/js/ui/polkitAuthenticationAgent.js @@ -37,7 +37,7 @@ const Meta = imports.gi.Meta; const Dialog = imports.ui.dialog; const Main = imports.ui.main; -const ModalDialog = imports.ui.modalDialog; +const PopupDialog = imports.ui.popupDialog; const CinnamonEntry = imports.ui.cinnamonEntry; const PopupMenu = imports.ui.popupMenu; const UserWidget = imports.ui.userWidget; @@ -46,8 +46,6 @@ const Util = imports.misc.util; const DIALOG_ICON_SIZE = 64; const DELAYED_RESET_TIMEOUT = 200; -const MAX_MODAL_RETRIES = 2; - var RootUser = class { constructor(name) { this.userName = name; @@ -115,7 +113,7 @@ var AdminUser = class { var AuthenticationDialog = GObject.registerClass({ Signals: { 'done': { param_types: [GObject.TYPE_BOOLEAN] } } -}, class AuthenticationDialog extends ModalDialog.ModalDialog { +}, class AuthenticationDialog extends PopupDialog.PopupDialog { _init(actionId, description, cookie, userNames) { super._init({ styleClass: 'prompt-dialog' }); @@ -128,8 +126,6 @@ var AuthenticationDialog = GObject.registerClass({ this._visibleAvatar = null; this._adminUsers = []; - this._modalRetryCount = 0; - this._sessionCompletedId = 0; this._sessionRequestId = 0; this._sessionShowErrorId = 0; @@ -356,29 +352,10 @@ var AuthenticationDialog = GObject.registerClass({ } _ensureOpen(focusEntry=false) { - // NOTE: ModalDialog.open() is safe to call if the dialog is - // already open - it just returns true without side-effects - if (!this.open(global.get_current_time())) { - // This can fail if e.g. unable to get input grab (double-click admin:// folder in nemo). - if (this._modalRetryCount < MAX_MODAL_RETRIES) { - this._modalRetryCount++; - GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { - this._ensureOpen(focusEntry); - return GLib.SOURCE_REMOVE; - }); + this.open(); - return; - } - - log('polkitAuthenticationAgent: Failed to show modal dialog.' + - ' Dismissing authentication request for action-id ' + this.actionId + - ' cookie ' + this._cookie); - this._emitDone(true); - } else { - if (focusEntry) { - this._passwordEntry.grab_key_focus(); - } - } + if (focusEntry) + this._passwordEntry.grab_key_focus(); } _emitDone(dismissed) { @@ -515,7 +492,7 @@ var AuthenticationDialog = GObject.registerClass({ } let resetDialog = () => { - if (this.state != ModalDialog.State.OPENED) + if (this.state != PopupDialog.State.OPENED) return; this._passwordEntry.hide(); @@ -614,7 +591,7 @@ var AuthenticationAgent = class { } _completeRequest(dismissed) { - this._currentDialog.close(global.get_current_time()); + this._currentDialog.close(); this._currentDialog = null; this._native.complete(dismissed); From 8e55a4a6e0d071d62dc0dbb54287c0dd462333ca Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Sat, 21 Mar 2026 12:35:19 -0400 Subject: [PATCH 4/6] modalDialog.js: Always raise to the top. A popup dialog may be visible, and may have been added after the modal (the RunDialog doesn't get destroyed once instantiated). --- js/ui/modalDialog.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/ui/modalDialog.js b/js/ui/modalDialog.js index 13801b5daa..08e9975e2a 100644 --- a/js/ui/modalDialog.js +++ b/js/ui/modalDialog.js @@ -111,6 +111,8 @@ class ModalDialog extends BaseDialog.BaseDialog { _fadeOpen() { this._monitorConstraint.index = global.display.get_current_monitor(); + Main.uiGroup.set_child_above_sibling(this, null); + if (this._lightbox) this._lightbox.lightOn(); From 21ecf83480791e002c53ff872c3e3b76ea73a382 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Sun, 22 Mar 2026 14:27:09 -0400 Subject: [PATCH 5/6] Remove unused _fadeOutDialog, cancel dnd if the dialog is destroyed during a drag, handle failed grab. --- js/ui/baseDialog.js | 1 - js/ui/modalDialog.js | 39 +----------------------------------- js/ui/popupDialog.js | 47 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 44 deletions(-) diff --git a/js/ui/baseDialog.js b/js/ui/baseDialog.js index 921e74b804..135a17074d 100644 --- a/js/ui/baseDialog.js +++ b/js/ui/baseDialog.js @@ -11,7 +11,6 @@ var State = { CLOSED: 1, OPENING: 2, CLOSING: 3, - FADED_OUT: 4 }; var BaseDialog = GObject.registerClass({ diff --git a/js/ui/modalDialog.js b/js/ui/modalDialog.js index 08e9975e2a..fd6618b8f4 100644 --- a/js/ui/modalDialog.js +++ b/js/ui/modalDialog.js @@ -23,15 +23,13 @@ const Main = imports.ui.main; const Gettext = imports.gettext; -const FADE_OUT_DIALOG_TIME = 1000; - var State = BaseDialog.State; /** * #ModalDialog: * @short_description: A generic object that displays a modal dialog * @state (ModalDialog.State): The state of the modal dialog, which may be - * `ModalDialog.State.OPENED`, `CLOSED`, `OPENING`, `CLOSING` or `FADED_OUT`. + * `ModalDialog.State.OPENED`, `CLOSED`, `OPENING` or `CLOSING`. * @contentLayout (St.BoxLayout): The box containing the contents of the modal * dialog (excluding the buttons) * @@ -218,41 +216,6 @@ class ModalDialog extends BaseDialog.BaseDialog { return true; } - /** - * _fadeOutDialog: - * @timestamp (int): (optional) timestamp optionally used to associate the - * call with a specific user initiated event - * - * This method is like %close(), but fades the dialog out much slower, - * and leaves the lightbox in place. Once in the faded out state, - * the dialog can be brought back by an open call, or the lightbox - * can be dismissed by a close call. - * - * The main point of this method is to give some indication to the user - * that the dialog response has been acknowledged but will take a few - * moments before being processed. - * - * e.g., if a user clicked "Log Out" then the dialog should go away - * immediately, but the lightbox should remain until the logout is - * complete. - */ - _fadeOutDialog(timestamp) { - if (this.state == State.CLOSED || this.state == State.CLOSING) - return; - - if (this.state == State.FADED_OUT) - return; - - this.popModal(timestamp); - this.dialogLayout.ease({ - opacity: 0, - duration: FADE_OUT_DIALOG_TIME, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - onComplete: () => { - this._setState(State.FADED_OUT); - } - }); - } }); /** diff --git a/js/ui/popupDialog.js b/js/ui/popupDialog.js index abe327d157..1f604358e4 100644 --- a/js/ui/popupDialog.js +++ b/js/ui/popupDialog.js @@ -39,15 +39,26 @@ class PopupDialog extends BaseDialog.BaseDialog { this._windowFocusChangedId = 0; this._dragCaptureId = 0; this._savedKeyFocus = null; + this._savedKeyFocusDestroyId = 0; this.dialogLayout = new Dialog.Dialog(this, params.styleClass); this._initDialogLayout(this.dialogLayout); - this.dialogLayout._dialog.x_align = Clutter.ActorAlign.FILL; - Main.uiGroup.add_actor(this); this._setupDragging(); + + this.connect('destroy', () => { + if (this._dragCaptureId) + this._endDrag(); + + if (this._windowFocusChangedId) { + global.display.disconnect(this._windowFocusChangedId); + this._windowFocusChangedId = 0; + } + + this._clearSavedKeyFocus(); + }); } open() { @@ -73,11 +84,16 @@ class PopupDialog extends BaseDialog.BaseDialog { if (this.state == State.CLOSED || this.state == State.CLOSING) return; + if (this._dragCaptureId) + this._endDrag(); + if (this._windowFocusChangedId) { global.display.disconnect(this._windowFocusChangedId); this._windowFocusChangedId = 0; } + this._clearSavedKeyFocus(); + Main.layoutManager.untrackChrome(this); global.set_stage_input_mode(Cinnamon.StageInputMode.NORMAL); this._animateClose(); @@ -103,10 +119,25 @@ class PopupDialog extends BaseDialog.BaseDialog { global.set_stage_input_mode(Cinnamon.StageInputMode.FOCUSED); } + _clearSavedKeyFocus() { + if (this._savedKeyFocusDestroyId) { + this._savedKeyFocus.disconnect(this._savedKeyFocusDestroyId); + this._savedKeyFocusDestroyId = 0; + } + this._savedKeyFocus = null; + } + _saveAndClearFocus() { + this._clearSavedKeyFocus(); + let focus = global.stage.key_focus; - if (focus && this.contains(focus)) + if (focus && this.contains(focus)) { this._savedKeyFocus = focus; + this._savedKeyFocusDestroyId = focus.connect('destroy', () => { + this._savedKeyFocus = null; + this._savedKeyFocusDestroyId = 0; + }); + } global.stage.set_key_focus(null); } @@ -115,8 +146,9 @@ class PopupDialog extends BaseDialog.BaseDialog { this._focusStage(); if (this._savedKeyFocus) { - this._savedKeyFocus.grab_key_focus(); - this._savedKeyFocus = null; + let actor = this._savedKeyFocus; + this._clearSavedKeyFocus(); + actor.grab_key_focus(); } else { this._grabInitialKeyFocus(); } @@ -166,6 +198,11 @@ class PopupDialog extends BaseDialog.BaseDialog { this._saveAndClearFocus(); this._dragGrabbed = global.begin_modal(global.get_current_time(), 0); + if (!this._dragGrabbed) { + this._restoreFocus(); + return; + } + global.display.set_cursor(Meta.Cursor.GRABBING); this._dragCaptureId = global.stage.connect('captured-event', (stageActor, ev) => { From 6dddf5bc30b1dd5e1d96bb12bca81d02f9ba8957 Mon Sep 17 00:00:00 2001 From: Michael Webster Date: Mon, 23 Mar 2026 09:47:06 -0400 Subject: [PATCH 6/6] Raise on click, don't lose default widget when focusing out, cancel close if open called. --- js/ui/popupDialog.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/js/ui/popupDialog.js b/js/ui/popupDialog.js index 1f604358e4..088183107c 100644 --- a/js/ui/popupDialog.js +++ b/js/ui/popupDialog.js @@ -65,7 +65,10 @@ class PopupDialog extends BaseDialog.BaseDialog { if (this.state == State.OPENED || this.state == State.OPENING) return true; - this._centerOnMonitor(); + if (this.state == State.CLOSING) + this.remove_all_transitions(); + else + this._centerOnMonitor(); Main.layoutManager.trackChrome(this, { affectsInputRegion: true }); @@ -128,10 +131,9 @@ class PopupDialog extends BaseDialog.BaseDialog { } _saveAndClearFocus() { - this._clearSavedKeyFocus(); - let focus = global.stage.key_focus; if (focus && this.contains(focus)) { + this._clearSavedKeyFocus(); this._savedKeyFocus = focus; this._savedKeyFocusDestroyId = focus.connect('destroy', () => { this._savedKeyFocus = null; @@ -164,6 +166,8 @@ class PopupDialog extends BaseDialog.BaseDialog { if (event.get_button() !== 1) return Clutter.EVENT_PROPAGATE; + Main.uiGroup.set_child_above_sibling(this, null); + let source = event.get_source(); if (this._isInteractiveActor(source)) { this._restoreFocus();