From f953a4ade74d9620aa8ccbb47e4e9750aaa840ea Mon Sep 17 00:00:00 2001 From: Clement Lefebvre Date: Thu, 30 Apr 2026 15:21:47 +0100 Subject: [PATCH] Implement Dialog Provides utilities to show info, error, dialog, question, custom dialogs. Compared to GTK: - Better alignment - Better button layout - Better DE integration --- tests/dialog.py | 268 +++++++++++++++++++++++++++++++++++++++ xapp/meson.build | 1 + xapp/widgets/Dialog.py | 154 ++++++++++++++++++++++ xapp/widgets/__init__.py | 3 +- 4 files changed, 425 insertions(+), 1 deletion(-) create mode 100755 tests/dialog.py create mode 100644 xapp/widgets/Dialog.py diff --git a/tests/dialog.py b/tests/dialog.py new file mode 100755 index 0000000..fee6bae --- /dev/null +++ b/tests/dialog.py @@ -0,0 +1,268 @@ +#!/usr/bin/python3 + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +from xapp.widgets import Dialog + +LOREM = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " + "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, " + "quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo " + "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse " + "cillum dolore eu fugiat nulla pariatur." +) + + +class DemoWindow(Gtk.Window): + + def __init__(self): + super().__init__(title="XApp.widgets.Dialog() Demo") + self.set_default_size(600, 500) + self.set_border_width(12) + + outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + + grid = Gtk.Grid(column_spacing=12, row_spacing=8) + + xapp_header = Gtk.Label() + xapp_header.set_markup("XApp") + grid.attach(xapp_header, 0, 0, 1, 1) + + gtk_header = Gtk.Label() + gtk_header.set_markup("Gtk") + grid.attach(gtk_header, 1, 0, 1, 1) + + rows = [ + ("Info", self._xapp_info, self._gtk_info), + ("Warning", self._xapp_warning, self._gtk_warning), + ("Error", self._xapp_error, self._gtk_error), + ("Question", self._xapp_question, self._gtk_question), + ("Custom", self._xapp_custom, self._gtk_custom), + ("No title", self._xapp_no_title, self._gtk_no_title), + ("Large text", self._xapp_large_text, self._gtk_large_text), + ("Hint", self._xapp_hint, self._gtk_hint), + ("Dialog no icon", self._xapp_dialog_no_icon, self._gtk_dialog_no_icon), + ("Dialog icon", self._xapp_dialog_icon, self._gtk_dialog_icon), + ("Widget", self._xapp_widget, self._gtk_widget), + ] + + for row, (label, xapp_cb, gtk_cb) in enumerate(rows, start=1): + xapp_btn = Gtk.Button(label=f"Show {label} dialog") + xapp_btn.connect("clicked", xapp_cb) + grid.attach(xapp_btn, 0, row, 1, 1) + + gtk_btn = Gtk.Button(label=f"Show {label} dialog") + gtk_btn.connect("clicked", gtk_cb) + grid.attach(gtk_btn, 1, row, 1, 1) + + outer_box.pack_start(grid, True, True, 0) + + self.result_label = Gtk.Label(label="Click a button to show a dialog.") + outer_box.pack_start(self.result_label, False, False, 0) + + self.add(outer_box) + self.connect("destroy", Gtk.main_quit) + self.show_all() + + def _set_result(self, text): + self.result_label.set_markup(f"Result: {text}") + + # XApp variants + + def _xapp_info(self, button): + Dialog(self, "All done", "The operation completed successfully.").info() + self._set_result("Info dialog closed") + + def _xapp_warning(self, button): + Dialog(self, "Disk space low", "You have less than 500 MB remaining.").warning() + self._set_result("Warning dialog closed") + + def _xapp_error(self, button): + Dialog(self, "Could not save file", "Permission denied: /etc/hosts").error() + self._set_result("Error dialog closed") + + def _xapp_question(self, button): + confirmed = Dialog(self, "Delete item?", "This action cannot be undone.").question() + self._set_result(f"Question answered: {'Yes' if confirmed else 'No'}") + + def _xapp_custom(self, button): + dialog = Dialog(self, "Apply changes?", "Your settings will be saved and the service restarted.") + dialog.add_button("Save", 1, "suggested-action") + dialog.add_button("Discard", 2, "destructive-action") + dialog.add_button("Cancel", 3) + dialog.set_default_response(2) + response = dialog.warning() + self._set_result(f"Custom dialog response: {response}") + + def _xapp_no_title(self, button): + Dialog(self, message="This dialog has no title.").info() + self._set_result("No-title dialog closed") + + def _xapp_large_text(self, button): + Dialog(self, "Lorem Ipsum", LOREM).info() + self._set_result("Large text dialog closed") + + def _xapp_hint(self, button): + Dialog(self, "Reset settings?", + "All preferences will be restored to their defaults.", + hint="This only affects the current user and can be undone by restoring a backup.").warning() + self._set_result("Hint dialog closed") + + def _xapp_dialog_no_icon(self, button): + Dialog(self, "Heads up", "This is a plain dialog with no icon.").dialog() + self._set_result("Dialog (no icon) closed") + + def _xapp_dialog_icon(self, button): + Dialog(self, "Bluetooth", "A new device has been paired.").dialog("bluetooth-symbolic") + self._set_result("Dialog (icon) closed") + + def _xapp_widget(self, button): + combo = Gtk.ComboBoxText() + for opt in ("Option A", "Option B", "Option C"): + combo.append_text(opt) + combo.set_active(0) + check = Gtk.CheckButton(label="Enable feature") + dialog = Dialog(self, "Settings", "Configure the following options:") + dialog.add_widget(combo) + dialog.add_widget(check) + response = dialog.info() + if response == Gtk.ResponseType.OK: + self._set_result(f"Widget dialog: {combo.get_active_text()!r}, enabled={check.get_active()}") + else: + self._set_result("Widget dialog cancelled") + + # Gtk variants + + def _gtk_info(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, + text="All done") + dialog.format_secondary_text("The operation completed successfully.") + dialog.run() + dialog.destroy() + self._set_result("Info dialog closed") + + def _gtk_warning(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, + text="Disk space low") + dialog.format_secondary_text("You have less than 500 MB remaining.") + dialog.run() + dialog.destroy() + self._set_result("Warning dialog closed") + + def _gtk_error(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, + text="Could not save file") + dialog.format_secondary_text("Permission denied: /etc/hosts") + dialog.run() + dialog.destroy() + self._set_result("Error dialog closed") + + def _gtk_question(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, + text="Delete item?") + dialog.format_secondary_text("This action cannot be undone.") + response = dialog.run() + dialog.destroy() + confirmed = response == Gtk.ResponseType.YES + self._set_result(f"Question answered: {'Yes' if confirmed else 'No'}") + + def _gtk_custom(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.NONE, + text="Apply changes?") + dialog.format_secondary_text("Your settings will be saved and the service restarted.") + dialog.add_button("Save", 1) + dialog.add_button("Discard", 2) + dialog.add_button("Cancel", 3) + dialog.get_widget_for_response(1).get_style_context().add_class("suggested-action") + dialog.get_widget_for_response(2).get_style_context().add_class("destructive-action") + response = dialog.run() + dialog.destroy() + self._set_result(f"Custom dialog response: {response}") + + def _gtk_no_title(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK) + dialog.format_secondary_text("This dialog has no title.") + dialog.run() + dialog.destroy() + self._set_result("No-title dialog closed") + + def _gtk_large_text(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, + text="Lorem Ipsum") + dialog.format_secondary_text(LOREM) + dialog.run() + dialog.destroy() + self._set_result("Large text dialog closed") + + def _gtk_hint(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, + text="Reset settings?") + dialog.format_secondary_text("All preferences will be restored to their defaults.") + hint_label = Gtk.Label() + hint_label.set_markup('This only affects the current user and can be undone by restoring a backup.') + hint_label.set_xalign(0) + hint_label.set_line_wrap(True) + dialog.get_message_area().pack_start(hint_label, False, False, 0) + hint_label.show() + dialog.run() + dialog.destroy() + self._set_result("Hint dialog closed") + + def _gtk_dialog_no_icon(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.OTHER, buttons=Gtk.ButtonsType.OK, + text="Heads up") + dialog.format_secondary_text("This is a plain dialog with no icon.") + dialog.run() + dialog.destroy() + self._set_result("Dialog (no icon) closed") + + def _gtk_dialog_icon(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.OTHER, buttons=Gtk.ButtonsType.OK, + text="Bluetooth") + dialog.format_secondary_text("A new device has been paired.") + image = Gtk.Image.new_from_icon_name("bluetooth", Gtk.IconSize.DIALOG) + dialog.set_image(image) + image.show() + dialog.run() + dialog.destroy() + self._set_result("Dialog (icon) closed") + + def _gtk_widget(self, button): + dialog = Gtk.MessageDialog(transient_for=self, modal=True, + message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, + text="Settings") + dialog.format_secondary_text("Configure the following options:") + combo = Gtk.ComboBoxText() + for opt in ("Option A", "Option B", "Option C"): + combo.append_text(opt) + combo.set_active(0) + check = Gtk.CheckButton(label="Enable feature") + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + vbox.pack_start(combo, False, False, 0) + vbox.pack_start(check, False, False, 0) + dialog.get_message_area().pack_start(vbox, False, False, 0) + vbox.show_all() + dialog.run() + self._set_result(f"Widget dialog: {combo.get_active_text()!r}, enabled={check.get_active()}") + dialog.destroy() + + +if __name__ == "__main__": + window = DemoWindow() + Gtk.main() diff --git a/xapp/meson.build b/xapp/meson.build index 1f33c62..3897220 100644 --- a/xapp/meson.build +++ b/xapp/meson.build @@ -13,6 +13,7 @@ python3.install_sources( [ 'widgets/__init__.py', 'widgets/ListEditor.py', + 'widgets/MessageDialog.py', ], subdir: 'xapp/widgets' ) diff --git a/xapp/widgets/Dialog.py b/xapp/widgets/Dialog.py new file mode 100644 index 0000000..95cf1bf --- /dev/null +++ b/xapp/widgets/Dialog.py @@ -0,0 +1,154 @@ +#!/usr/bin/python3 + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + + +class Dialog: + """ + A builder for message dialogs. Instantiate with content, optionally add + custom buttons, then call info(), warning(), error(), question() or dialog() + to show it. + + If no buttons are added, sensible defaults are used based on the call type. + """ + + def __init__(self, parent, title=None, message=None, hint=None): + self._parent = parent + self._title = title + self._message = message + self._hint = hint + self._buttons = [] # list of (label, response_id, style_class) + self._widgets = [] # list of extra widgets to add below the message + self._default_response_id = None + + def add_button(self, label, response_id, style=None): + self._buttons.append((label, response_id, style)) + + def add_widget(self, widget): + self._widgets.append(widget) + + def set_default_response(self, response_id): + self._default_response_id = response_id + + def _run(self, icon_name, default_buttons): + dlg = Gtk.Dialog( + title="", + transient_for=self._parent, + modal=True, + destroy_with_parent=True, + ) + dlg.set_default_size(360, -1) + dlg.set_resizable(False) + + headerbar = Gtk.HeaderBar() + headerbar.set_show_close_button(False) + dlg.set_titlebar(headerbar) + + css_provider = Gtk.CssProvider() + css_provider.load_from_data(b""" + headerbar { + background: @theme_bg_color; + background-image: none; + border: none; + box-shadow: none; + min-height: 0; + padding: 0; + } + """) + headerbar.get_style_context().add_provider( + css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + # Content grid: icon in col 0, labels in col 1 + grid = Gtk.Grid() + grid.set_border_width(12) + grid.set_column_spacing(12) + grid.set_row_spacing(6) + grid.set_halign(Gtk.Align.CENTER) + + row = 0 + + if self._title: + title_label = Gtk.Label() + title_label.set_markup(f"{self._title}") + title_label.set_xalign(0) + title_label.set_line_wrap(True) + title_label.set_max_width_chars(60) + grid.attach(title_label, 1, row, 1, 1) + row += 1 + + if self._message is not None: + message_label = Gtk.Label(label=self._message) + message_label.set_xalign(0) + message_label.set_line_wrap(True) + message_label.set_max_width_chars(60) + grid.attach(message_label, 1, row, 1, 1) + row += 1 + + if self._hint is not None: + hint_label = Gtk.Label() + hint_label.set_markup(f'{self._hint}') + hint_label.set_xalign(0) + hint_label.set_line_wrap(True) + hint_label.set_max_width_chars(60) + grid.attach(hint_label, 1, row, 1, 1) + row += 1 + + if icon_name is not None: + image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) + image.set_valign(Gtk.Align.START) + grid.attach(image, 0, 0, 1, row) + + dlg.get_content_area().add(grid) + + for widget in self._widgets: + grid.attach(widget, 1, row, 1, 1) + row += 1 + + # Buttons + button_box = dlg.get_action_area() + button_box.get_style_context().remove_class("linked") + button_box.set_margin_top(6) + button_box.set_margin_bottom(6) + button_box.set_margin_start(6) + button_box.set_margin_end(6) + button_box.set_spacing(6) + + buttons = self._buttons if self._buttons else default_buttons + for label, response_id, style in buttons: + btn = dlg.add_button(label, response_id) + if style: + btn.get_style_context().add_class(style) + + if self._default_response_id: + dlg.set_default_response(self._default_response_id) + + dlg.show_all() + response = dlg.run() + for widget in self._widgets: + grid.remove(widget) + dlg.destroy() + return response + + def info(self): + return self._run("dialog-information", [(Gtk.STOCK_OK, Gtk.ResponseType.OK, None)]) + + def warning(self): + return self._run("dialog-warning", [(Gtk.STOCK_OK, Gtk.ResponseType.OK, None)]) + + def error(self): + return self._run("dialog-error", [(Gtk.STOCK_OK, Gtk.ResponseType.OK, None)]) + + def question(self): + """Returns True if the user clicked Yes, False otherwise.""" + response = self._run("dialog-question", [ + (Gtk.STOCK_NO, Gtk.ResponseType.NO, None), + (Gtk.STOCK_YES, Gtk.ResponseType.YES, None), + ]) + return response == Gtk.ResponseType.YES + + def dialog(self, icon_name=None): + """Show a plain dialog with no message type icon, or a custom icon by name.""" + return self._run(icon_name, [(Gtk.STOCK_OK, Gtk.ResponseType.OK, None)]) diff --git a/xapp/widgets/__init__.py b/xapp/widgets/__init__.py index 441576f..4abe6ea 100644 --- a/xapp/widgets/__init__.py +++ b/xapp/widgets/__init__.py @@ -1,4 +1,5 @@ from .ListEditor import ListEditor +from .Dialog import Dialog -__all__ = ["ListEditor"] +__all__ = ["ListEditor", "Dialog"]