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"]