diff --git a/OpenPrintingRequest.py b/OpenPrintingRequest.py
index 4f1a1f177..1426b893b 100644
--- a/OpenPrintingRequest.py
+++ b/OpenPrintingRequest.py
@@ -27,7 +27,7 @@
import cupshelpers
from debug import *
-from gi.repository import GObject
+from gi.repository import GObject, GLib
class OpenPrintingRequest(GObject.GObject):
__gsignals__ = {
@@ -79,7 +79,7 @@ def _printers_got (self, status, user_data, printers):
self._handle = None
if status != 0:
debugprint ("%s -> 'error'" % self)
- self.emit ('error', status, printers)
+ GLib.idle_add (self.emit, 'error', status, printers)
return
self.downloadable_printers_unchecked = [(x, printers[x])
@@ -130,7 +130,7 @@ def _printer_drivers_got (self, status, user_data, drivers):
self._handle = None
if status != 0:
debugprint ("%s -> 'error'" % self)
- self.emit ('error', status, drivers)
+ GLib.idle_add (self.emit, 'error', status, drivers)
return
if drivers:
@@ -163,9 +163,9 @@ def _printer_drivers_got (self, status, user_data, drivers):
def _drivers_got (self):
self._handle = None
debugprint ("%s -> 'finished'" % self)
- self.emit ('finished',
- self.downloadable_printers,
- self.downloadable_drivers)
+ GLib.idle_add (self.emit, 'finished',
+ self.downloadable_printers,
+ self.downloadable_drivers)
if __name__ == '__main__':
from pprint import pprint
diff --git a/newprinter.py b/newprinter.py
index fdab9df98..d6daec94c 100644
--- a/newprinter.py
+++ b/newprinter.py
@@ -63,6 +63,7 @@
import ppdsloader
import dnssdresolve
import installpackage
+from vectorspinner import VectorSpinner
import gettext
gettext.install(domain=config.PACKAGE, localedir=config.localedir)
@@ -237,7 +238,6 @@ def __init__(self):
self.nextnptab_rerun = False
self.printers = {} # set in init()
self.recommended_model_selected = False
- self._searchdialog = None
self._installdialog = None
self.getWidgets({"NewPrinterWindow":
@@ -414,7 +414,13 @@ def __init__(self):
self.ntbkNPDownloadableDriverProperties.set_show_tabs(False)
self.spinner_count = 0
-
+ old_spinner = self.spinner
+ spinner_parent = old_spinner.get_parent ()
+ if spinner_parent is not None:
+ spinner_parent.remove (old_spinner)
+ self.spinner = VectorSpinner (size=32)
+ spinner_parent.pack_start (self.spinner, False, True, 0)
+ spinner_parent.reorder_child (self.spinner, 0)
# Set up OpenPrinting widgets.
self.opreq = None
self.opreq_handlers = None
@@ -462,23 +468,41 @@ def protect_toggle (toggle_widget):
self.tvNPDevices.connect ("row-activated", self.device_row_activated)
self.tvNPDevices.connect ("row-expanded", self.device_row_expanded)
- # inline searching spinner
- scrolled = self.tvNPDevices.get_parent ()
- parent_box = scrolled.get_parent ()
- if parent_box is not None:
- self._searching_overlay = Gtk.Overlay ()
- parent_box.remove (scrolled)
- self._searching_overlay.add (scrolled)
- parent_box.pack_start (self._searching_overlay, True, True, 0)
- parent_box.reorder_child (self._searching_overlay, 0)
-
- self._searching_spinner = Gtk.Spinner ()
- self._searching_spinner.set_halign (Gtk.Align.CENTER)
- self._searching_spinner.set_valign (Gtk.Align.CENTER)
- self._searching_spinner.set_size_request (32, 32)
- self._searching_overlay.add_overlay (self._searching_spinner)
- self._searching_overlay.show_all ()
- self._searching_spinner.hide ()
+ # Searching spinner — placed in a Gtk.Stack alongside the Description
+ # notebook. This prevents the left and right panes from resizing when
+ # switching between them, as the Stack maintains the max size of both.
+ vbNPDevices = self.ntbkNPType.get_parent ()
+ if vbNPDevices is not None:
+ self._searching_stack = Gtk.Stack()
+ self._searching_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
+ self._searching_stack.set_homogeneous(True)
+
+ vbNPDevices.remove(self.ntbkNPType)
+ self._searching_stack.add_named(self.ntbkNPType, "notebook")
+
+ self._searching_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ self._searching_vbox.set_halign(Gtk.Align.CENTER)
+ self._searching_vbox.set_valign(Gtk.Align.CENTER)
+ self._searching_vbox.set_vexpand(True)
+ self._searching_vbox.set_hexpand(True)
+
+ self._searching_spinner = VectorSpinner (size=48)
+ self._searching_spinner.set_halign(Gtk.Align.CENTER)
+
+ self._searching_label = Gtk.Label()
+ self._searching_label.set_use_markup(True)
+ self._searching_label.set_halign(Gtk.Align.CENTER)
+ self._searching_label.set_justify(Gtk.Justification.CENTER)
+ self._searching_vbox.pack_start(self._searching_spinner, False, False, 0)
+ self._searching_vbox.pack_start(self._searching_label, False, False, 0)
+
+ self._searching_stack.add_named(self._searching_vbox, "spinner")
+ vbNPDevices.pack_start(self._searching_stack, True, True, 0)
+ vbNPDevices.reorder_child(self._searching_stack, 0)
+ self._searching_stack.show_all ()
+
+ # Start with the notebook visible
+ self._searching_stack.set_visible_child_name("notebook")
else:
self._searching_spinner = None
@@ -847,6 +871,7 @@ def on_ppdsloader_finished_next (self, ppdsloader):
self._getPPDs_reply (ppdsloader)
if not self.ppds:
+ self.setNPButtons()
return
if ppdsloader._jockey_has_answered:
@@ -862,6 +887,7 @@ def on_ppdsloader_finished_next (self, ppdsloader):
# get PPDs
def _getPPDs_reply (self, ppdsloader):
+ self._hide_searching_spinner()
exc = ppdsloader.get_error ()
if exc:
ppdsloader.destroy ()
@@ -940,6 +966,7 @@ def on_NPCancel(self, widget, event=None):
if self.ppdsloader:
self.ppdsloader.destroy ()
self.ppdsloader = None
+ self._hide_searching_spinner()
if self.printer_finder:
self.printer_finder.cancel ()
@@ -947,7 +974,8 @@ def on_NPCancel(self, widget, event=None):
self.dec_spinner_task ()
self.NewPrinterWindow.hide()
- if self.opreq is not None:
+ self._hide_searching_spinner()
+ if getattr(self, 'opreq', None) is not None:
for handler in self.opreq_handlers:
self.opreq.disconnect (handler)
@@ -1025,42 +1053,112 @@ def do_installdriverpackage(self, name, repo, keyid):
self.p = subprocess.Popen (args, env=new_environ, close_fds=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE)
- # Keep the UI refreshed while we wait for
- # the drivers query to complete.
- (stdout, stderr) = (self.p.stdout, self.p.stderr)
- done = False
+ # Use a nested main loop and IO watch for event-driven async I/O
+ loop = GLib.MainLoop()
pbar = self._installdialog._progress_bar
- while self.p.poll() is None:
- line = stdout.readline ().strip()
- if (len(line) > 0):
- if line == "done":
- done = True
- break
- elif line.startswith(b"P"):
- try:
- percentage = float(line[1:])
- if percentage >= 0:
- pbar.set_fraction(percentage/100)
- else:
- pbar.set_pulse_step(-percentage/100)
- pbar.pulse()
- except:
- pass
- else:
- self.installed_driver_files.append(line.decode("utf-8"));
- while Gtk.events_pending ():
- Gtk.main_iteration ()
+
+ # Make stdout non-blocking
+ import fcntl
+ flags = fcntl.fcntl(self.p.stdout.fileno(), fcntl.F_GETFL)
+ fcntl.fcntl(self.p.stdout.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
+
+ done_received = False
+ child_exited = False
+ eof_reached = False
+ io_error = False
+ exit_status = -1
+
+ def check_done():
+ if child_exited and (eof_reached or io_error):
+ loop.quit()
+
+ def process_line(line):
+ nonlocal done_received
+ line = line.strip()
if not line:
- time.sleep (0.1)
- if self.p.returncode != 0 and not done:
+ return
+ if line == b"done":
+ done_received = True
+ elif line.startswith(b"P"):
+ try:
+ percentage = float(line[1:])
+ if percentage >= 0:
+ pbar.set_fraction(percentage/100)
+ else:
+ pbar.set_pulse_step(-percentage/100)
+ pbar.pulse()
+ except:
+ pass
+ else:
+ self.installed_driver_files.append(line.decode("utf-8", errors="replace"))
+
+ buffer = b""
+ def on_stdout_ready(source, condition):
+ nonlocal buffer, eof_reached, io_error
+ import os as _os
+ fd = source if isinstance(source, int) else source.fileno()
+
+ while True:
+ try:
+ data = _os.read(fd, 4096)
+ if not data:
+ eof_reached = True
+ break
+ buffer += data
+ while b"\n" in buffer:
+ line, buffer = buffer.split(b"\n", 1)
+ process_line(line)
+ except BlockingIOError:
+ break
+ except Exception as e:
+ debugprint("Unexpected IO error reading from subprocess: %s" % e)
+ io_error = True
+ break
+
+ if condition & GLib.IOCondition.ERR:
+ debugprint("GLib.IOCondition.ERR received on subprocess stdout")
+ io_error = True
+
+ if eof_reached or io_error or (condition & GLib.IOCondition.HUP):
+ if buffer:
+ process_line(buffer)
+ buffer = b""
+ if condition & GLib.IOCondition.HUP:
+ eof_reached = True
+ check_done()
+ return False
+ return True
+
+ def on_child_exit(pid, status):
+ nonlocal child_exited, exit_status
+ child_exited = True
+ exit_status = status
+ check_done()
+
+ watch_id = GLib.io_add_watch(self.p.stdout,
+ GLib.PRIORITY_DEFAULT,
+ GLib.IOCondition.IN | GLib.IOCondition.HUP | GLib.IOCondition.ERR,
+ on_stdout_ready)
+ child_watch_id = GLib.child_watch_add(self.p.pid, on_child_exit)
+
+ # Block here, processing UI events and I/O until both EOF and child exit occur
+ loop.run()
+
+ # Convert exit status to return code. waitpid returns a 16-bit status.
+ import os as _os
+ returncode = _os.waitstatus_to_exitcode(exit_status) if hasattr(_os, 'waitstatus_to_exitcode') else (exit_status >> 8)
+ if returncode != 0 and not done_received:
ret = False
- except:
- # Problem executing command.
+ if io_error:
+ ret = False
+
+ except Exception as e:
+ debugprint("Error in do_installdriverpackage: %s" % e)
ret = False
if not ret:
- self.installed_driver_files = [];
+ self.installed_driver_files = []
return ret
@@ -1131,6 +1229,7 @@ def nextNPTab(self, step=1):
if not keep_going:
debugprint ('Interrupting execution of nextNPTab(): Operations pending')
+ self.btnNPForward.set_sensitive (False)
return
order = self._getPagesOrderForDialogMode ()
@@ -1185,7 +1284,7 @@ def nextNPTab(self, step=1):
if (self.device.id and
not self.device.type in ("socket", "lpd", "ipp",
"http", "https", "bluetooth")):
- name = "%s %s" % (self.device.id_dict["MFG"],
+ name = "%s %s" % (self.device.id_dict["MFG"],
self.device.id_dict["MDL"])
except:
nonfatalException ()
@@ -1564,6 +1663,7 @@ def _installHPScannerFilesIfNeeded (self):
def _loadPPDsForDevice (self, devid, uri):
debugprint ("nextNPTab: need PPDs loaded")
+ self._show_searching_spinner(_("Searching for drivers"))
p = ppdsloader.PPDsLoader (device_id=devid,
device_uri=uri,
parent=self.NewPrinterWindow,
@@ -1709,23 +1809,8 @@ def _installPrinterOrSearchForDriver (self, devid, ppdname, status, page_nr, ste
debugprint ('nextNPTab: Searching for "%s"' % devid)
self.searchedfordriverpackages = True
- self._searchdialog_canceled = False
- fmt = _("Searching")
- self._searchdialog = Gtk.MessageDialog (
- parent=self.NewPrinterWindow,
- modal=True,
- destroy_with_parent=True,
- message_type=Gtk.MessageType.INFO,
- buttons=Gtk.ButtonsType.CANCEL,
- text=fmt)
-
- self._searchdialog.format_secondary_text (
- _("Searching for drivers"))
-
self.opreq = OpenPrintingRequest ()
- self._searchdialog.connect (
- "response", self._searchdialog_response)
- self._searchdialog.show_all ()
+ self._show_searching_spinner(_("Searching for drivers"))
self.opreq_handlers = []
self.opreq_handlers.append (
@@ -1753,22 +1838,6 @@ def _installPrinterOrSearchForDriver (self, devid, ppdname, status, page_nr, ste
# No operations are pending if reached.
return self.INSTALL_RESULT_DONE
- def _searchdialog_response (self, dialog, response):
- # Cancel clicked while performing openprinting search
-
- self.btnNPDownloadableDriverSearch.set_sensitive (True)
- self.btnNPDownloadableDriverSearch_label.set_text (_("Search"))
-
- self.installed_driver_files = []
- self.searchedfordriverpackages = True
- self.founddownloadabledrivers = False
- self.founddownloadableppd = False
-
- ready (self.NewPrinterWindow)
-
- # Cancel the openprinting request.
- GLib.idle_add (self.opreq.cancel)
-
def opreq_id_search_done (self, opreq, printers, drivers):
for handler in self.opreq_handlers:
opreq.disconnect (handler)
@@ -1776,9 +1845,7 @@ def opreq_id_search_done (self, opreq, printers, drivers):
self.opreq_user_search = False
self.opreq_handlers = None
self.opreq = None
- self._searchdialog.hide ()
- self._searchdialog.destroy ()
- self._searchdialog = None
+ self._hide_searching_spinner()
# Check whether we have found something
@@ -1799,7 +1866,7 @@ def opreq_id_search_done (self, opreq, printers, drivers):
try:
self.NewPrinterWindow.show()
self.setNPButtons()
-
+
if not self.fillDownloadableDrivers():
ready(self.NewPrinterWindow)
self.founddownloadabledrivers = False
@@ -2358,28 +2425,33 @@ def adjust_firewall_response (self, dialog, response):
self.firewall.write ()
debugprint ("Fetching network devices after firewall dialog response")
- self._show_searching_spinner ()
+ self._show_searching_spinner (_("Searching for printers"))
self.fetchDevices_conn = asyncconn.Connection ()
self.fetchDevices_conn._begin_operation (_("fetching device list"))
self.fetchDevices (network=True)
def start_fetching_devices (self):
- self._show_searching_spinner ()
+ self._show_searching_spinner (_("Searching for printers"))
self.fetchDevices_conn = asyncconn.Connection ()
self.fetchDevices_conn._begin_operation (_("fetching device list"))
self.fetchDevices (network=False, current_uri=self.current_uri)
del self.current_uri
- def _show_searching_spinner (self):
- if self._searching_spinner is not None:
+ def _show_searching_spinner (self, text=""):
+ if getattr(self, '_searching_stack', None) is not None:
+ if text:
+ self._searching_label.set_markup("%s" % text)
+ self._searching_label.show()
+ else:
+ self._searching_label.hide()
self._searching_spinner.start ()
- self._searching_spinner.show ()
+ self._searching_stack.set_visible_child_name("spinner")
def _hide_searching_spinner (self):
- if self._searching_spinner is not None:
- self._searching_spinner.hide ()
+ if getattr(self, '_searching_stack', None) is not None:
self._searching_spinner.stop ()
+ self._searching_stack.set_visible_child_name("notebook")
def add_devices (self, devices, current_uri, no_more=False):
if no_more:
@@ -2440,7 +2512,7 @@ def replace_generic (device):
else:
device2.uri = "delete"
devices = [x for x in devices if x.uri not in ("hp", "hpfax",
- "hal", "beh", "smb",
+ "hal", "beh", "smb",
"scsi", "http", "bjnp",
"delete")]
@@ -2523,7 +2595,7 @@ def replace_generic (device):
row=[info, device, False]
if network:
if devs[0].uri != devs[0].type:
- # An actual network printer device. Put this at the top.
+ # Show discovered network printers as selectable top-level devices.
iter = model.insert_before (network_iter, find_nw_iter,
row=row)
if device == current_device:
@@ -4433,6 +4505,7 @@ def on_btnNPApply_clicked(self, widget):
if self.ppdsloader:
self.ppdsloader.destroy ()
self.ppdsloader = None
+ self._hide_searching_spinner()
if self.printer_finder:
self.printer_finder.cancel ()
diff --git a/ppdsloader.py b/ppdsloader.py
index 7be92eedc..3942fe5b3 100644
--- a/ppdsloader.py
+++ b/ppdsloader.py
@@ -19,12 +19,14 @@
## along with this program; if not, write to the Free Software
## Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+import threading
+
import dbus
-from gi.repository import GObject
+from gi.repository import GObject, GLib
from gi.repository import Gtk
-import cupshelpers
import cups
+import cupshelpers
cups.require ("1.9.52")
import asyncconn
@@ -74,6 +76,7 @@ def __init__ (self, device_id=None, parent=None, device_uri=None,
self._ppdsmatch_result = None
self._jockey_queried = False
self._jockey_has_answered = False
+ self._destroyed = False
self._local_cups = (self._host is None or
self._host == "localhost" or
self._host[0] == '/')
@@ -83,19 +86,8 @@ def __init__ (self, device_id=None, parent=None, device_uri=None,
debugprint ("Failed to get session bus")
self._bus = None
- fmt = _("Searching")
- self._dialog = Gtk.MessageDialog (parent=parent,
- modal=True, destroy_with_parent=True,
- message_type=Gtk.MessageType.INFO,
- buttons=Gtk.ButtonsType.CANCEL,
- text=fmt)
-
- self._dialog.format_secondary_text (_("Searching for drivers"))
-
- self._dialog.connect ("response", self._dialog_response)
def run (self):
- self._dialog.show_all ()
if self._device_id:
self._devid_dict = cupshelpers.parseDeviceID (self._device_id)
@@ -116,9 +108,7 @@ def __del__ (self):
def destroy (self):
debugprint ("DESTROY: %s" % self)
- if self._dialog:
- self._dialog.destroy ()
- self._dialog = None
+ self._destroyed = True
self._parent = None
@@ -139,11 +129,6 @@ def get_error (self):
debugprint ("%s: stored error is %s" % (self, repr (self._exc)))
return self._exc
- def _dialog_response (self, dialog, response):
- dialog.destroy ()
- self._dialog = None
- self.emit ('finished')
-
def _query_cups (self):
debugprint ("Asking CUPS for PPDs")
if (not self._conn):
@@ -156,11 +141,17 @@ def _query_cups (self):
self._cups_connect_reply(self._conn, None)
def _cups_connect_reply (self, conn, UNUSED):
+ if self._destroyed:
+ return
+
conn._begin_operation (_("fetching PPDs"))
conn.getPPDs2 (reply_handler=self._cups_reply,
error_handler=self._cups_error)
def _cups_reply (self, conn, result):
+ if self._destroyed:
+ return
+
ppds = cupshelpers.ppds.PPDs (result, language=self._language)
self._ppds = ppds
self._need_requery_cups = False
@@ -196,56 +187,59 @@ def _cups_reply (self, conn, result):
conn.destroy ()
self._conn = None
- if self._dialog is not None:
- self._dialog.destroy ()
- self._dialog = None
self.emit ('finished')
def _cups_error (self, conn, exc):
+ if self._destroyed:
+ return
+
conn.destroy ()
self._conn = None
self._ppds = None
self._exc = exc
- if self._dialog is not None:
- self._dialog.destroy ()
- self._dialog = None
self.emit ('finished')
def _query_packagekit (self):
debugprint ("Asking PackageKit to install drivers")
- try:
- obj = self._bus.get_object ("org.freedesktop.PackageKit",
- "/org/freedesktop/PackageKit")
- proxy = dbus.Interface (obj, "org.freedesktop.PackageKit.Modify")
- resources = [self._gpk_device_id]
- interaction = "hide-finished"
- debugprint ("Calling InstallPrinterDrivers (%s, %s, %s)" %
- (repr (0), repr (resources), repr (interaction)))
- proxy.InstallPrinterDrivers (dbus.UInt32 (0),
- resources, interaction,
- reply_handler=self._packagekit_reply,
- error_handler=self._packagekit_error,
- timeout=3600)
- except Exception as e:
- debugprint ("Failed to talk to PackageKit: %s" % repr (e))
- if self._dialog:
- self._dialog.show_all ()
- self._query_cups ()
-
- def _packagekit_reply (self):
- debugprint ("Got PackageKit reply")
- self._need_requery_cups = True
- if self._dialog:
- self._dialog.show_all ()
- self._query_cups ()
- def _packagekit_error (self, exc):
- debugprint ("Got PackageKit error: %s" % repr (exc))
- if self._dialog:
- self._dialog.show_all ()
- self._query_cups ()
+ gpk_device_id = self._gpk_device_id
+
+ def worker():
+ bus = None
+ success = False
+ try:
+ bus = dbus.SessionBus(private=True)
+ obj = bus.get_object("org.freedesktop.PackageKit",
+ "/org/freedesktop/PackageKit")
+ proxy = dbus.Interface(obj, "org.freedesktop.PackageKit.Modify")
+ resources = [gpk_device_id]
+ interaction = "hide-finished"
+ debugprint("Calling InstallPrinterDrivers in worker")
+ proxy.InstallPrinterDrivers(dbus.UInt32(0), resources, interaction, timeout=3600)
+ success = True
+ except Exception as e:
+ debugprint("Got PackageKit error in worker: %s" % repr(e))
+ finally:
+ if bus is not None:
+ bus.close()
+ GLib.idle_add(self._on_packagekit_done, success)
+
+ threading.Thread(target=worker, daemon=True).start()
+
+ def _on_packagekit_done(self, success):
+ if self._destroyed:
+ return False
+
+ if not success:
+ debugprint("PackageKit installation failed or returned error")
+ else:
+ debugprint("Got PackageKit reply")
+ self._need_requery_cups = True
+
+ self._query_cups()
+ return False
def _query_jockey (self):
debugprint ("Asking Jockey to install drivers")
@@ -260,6 +254,9 @@ def _query_jockey (self):
self._jockey_error (e)
def _jockey_reply (self, conn, result):
+ if self._destroyed:
+ return
+
debugprint ("Got Jockey result: %s" % repr (result))
self._jockey_has_answered = True
try:
@@ -269,6 +266,9 @@ def _jockey_reply (self, conn, result):
self._query_cups ()
def _jockey_error (self, exc):
+ if self._destroyed:
+ return
+
debugprint ("Got Jockey error: %s" % repr (exc))
if self._need_requery_cups:
self._query_cups ()
@@ -277,9 +277,6 @@ def _jockey_error (self, exc):
self._conn.destroy ()
self._conn = None
- if self._dialog is not None:
- self._dialog.destroy ()
- self._dialog = None
self.emit ('finished')
diff --git a/system-config-printer.py b/system-config-printer.py
index 521abc6f0..087f11043 100755
--- a/system-config-printer.py
+++ b/system-config-printer.py
@@ -27,6 +27,11 @@
import sys, os, time, re
import _thread
import dbus
+import dbus.mainloop.glib
+
+# Must be initialized before any threads use D-Bus.
+dbus.mainloop.glib.threads_init()
+
import gi
try:
gi.require_version('Polkit', '1.0')
diff --git a/timedops.py b/timedops.py
index 4af184c8c..9c3f38725 100644
--- a/timedops.py
+++ b/timedops.py
@@ -18,7 +18,6 @@
## along with this program; if not, write to the Free Software
## Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-import dbus.mainloop.glib
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gdk
@@ -30,10 +29,6 @@
gettext.install(domain=config.PACKAGE, localedir=config.localedir)
from debug import *
-# Initialise threading for D-Bus. This is needed as long as it is
-# used from two separate threads. We only do this in a few places
-# now, but in particular the troubleshooter does this (bug #662047).
-dbus.mainloop.glib.threads_init ()
class OperationCanceled(RuntimeError):
pass
diff --git a/ui/NewPrinterWindow.ui b/ui/NewPrinterWindow.ui
index 774960f09..2e05cfcc9 100644
--- a/ui/NewPrinterWindow.ui
+++ b/ui/NewPrinterWindow.ui
@@ -3335,7 +3335,11 @@ ipp://printer.mydomain/ipp
12
False
diff --git a/vectorspinner.py b/vectorspinner.py
new file mode 100644
index 000000000..01e6ca851
--- /dev/null
+++ b/vectorspinner.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+## vectorspinner.py - Custom Cairo-drawn vector spinner widget
+
+## A theme-independent spinner that renders identically across all
+## GTK themes and Linux distributions.
+## Authors:
+## Alexander Pevzner
+## Ayush Singh
+
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+## GNU General Public License for more details.
+
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+import math
+import cairo
+
+from gi.repository import Gtk
+from gi.repository import GLib
+
+
+class VectorSpinner(Gtk.DrawingArea):
+ """A custom spinner widget drawn with Cairo vectors.
+
+ Note: We must use a custom implementation because the standard Gtk.Spinner
+ is unfortunately not customizable, at least in some Linux distributions
+ and system themes where CSS overrides for size are ignored.
+
+ Unlike Gtk.Spinner, this renders identically across all GTK themes
+ and distributions since it draws its own animation frames using
+ Cairo vector paths.
+ """
+
+ def __init__(self, size=32, interval=20, num_lines=12):
+ """
+ Args:
+ size: Widget width and height in pixels.
+ interval: Animation frame interval in milliseconds.
+ num_lines: Number of radial lines in the spinner.
+ """
+ super().__init__()
+ self._size = size
+ self._interval = interval
+ self._num_lines = num_lines
+ self._step = 0
+ self._timer_id = None
+
+ self.set_size_request(size, size)
+ self.connect("draw", self._on_draw)
+
+ def start(self):
+ """Start the spinner animation."""
+ if self._timer_id is None:
+ self._timer_id = GLib.timeout_add(self._interval, self._tick)
+
+ def stop(self):
+ """Stop the spinner animation."""
+ if self._timer_id is not None:
+ GLib.source_remove(self._timer_id)
+ self._timer_id = None
+
+ def _tick(self):
+ """Advance one animation frame smoothly."""
+
+ self._step = (self._step + 1) % 36
+ self.queue_draw()
+ return True
+
+ def _on_draw(self, widget, cr):
+ """Draw a modern, continuous rotating ring."""
+ size = self._size
+ center = size / 2.0
+ radius = size * 0.35
+ line_width = max(size * 0.08, 2.0)
+
+ cr.set_line_width(line_width)
+ cr.set_line_cap(cairo.LINE_CAP_ROUND)
+ cr.set_source_rgba(0.4, 0.4, 0.4, 1.0)
+ angle_offset = self._step * (2 * math.pi / 36)
+ start_angle = angle_offset
+ end_angle = angle_offset + (math.pi * 1.5) # 270 degree solid arc
+
+ cr.arc(center, center, radius, start_angle, end_angle)
+ cr.stroke()
+
+ return False
+