diff --git a/src/redshift-gtk/controller.py b/src/redshift-gtk/controller.py index 24c58ae7..6f65784e 100644 --- a/src/redshift-gtk/controller.py +++ b/src/redshift-gtk/controller.py @@ -20,6 +20,7 @@ import re import fcntl import signal +import gettext import gi gi.require_version('GLib', '2.0') @@ -27,26 +28,33 @@ from gi.repository import GLib, GObject from . import defs +try: + from . import watch_events +except (ImportError, ValueError): + watch_events = None + +_ = gettext.gettext class RedshiftController(GObject.GObject): - """GObject wrapper around the Redshift child process.""" + '''A GObject wrapper around the child process''' __gsignals__ = { 'inhibit-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), 'temperature-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)), 'period-changed': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 'location-changed': (GObject.SIGNAL_RUN_FIRST, None, (float, float)), + 'fullscreen-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), 'error-occured': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 'stopped': (GObject.SIGNAL_RUN_FIRST, None, ()), - } + } def __init__(self, args): - """Initialize controller and start child process. + '''Initialize controller and start child process The parameter args is a list of command line arguments to pass on to - the child process. The "-v" argument is automatically added. - """ + the child process. The "-v" argument is automatically added.''' + GObject.GObject.__init__(self) # Initialize state variables @@ -54,6 +62,17 @@ def __init__(self, args): self._temperature = 0 self._period = 'Unknown' self._location = (0.0, 0.0) + self._fullscreen = False + self._manually_inhibited = False + self.detect_fullscreen = False + self._thread = None + + # Toggle fullscreen detection + if watch_events is not None: + self.detect_fullscreen = True + if '-f' in args: + args.remove('-f') + self._fullscreen = True # Start redshift with arguments args.insert(0, os.path.join(defs.BINDIR, 'redshift')) @@ -62,13 +81,14 @@ def __init__(self, args): # Start child process with C locale so we can parse the output env = os.environ.copy() - for key in ('LANG', 'LANGUAGE', 'LC_ALL', 'LC_MESSAGES'): - env[key] = 'C' - self._process = GLib.spawn_async( - args, envp=['{}={}'.format(k, v) for k, v in env.items()], - flags=GLib.SPAWN_DO_NOT_REAP_CHILD, - standard_output=True, standard_error=True) - + env['LANG'] = env['LANGUAGE'] = env['LC_ALL'] = env['LC_MESSAGES'] = 'C' + self._process = GLib.spawn_async(args, envp=['{}={}'.format(k,v) for k, v in env.items()], + flags=GLib.SPAWN_DO_NOT_REAP_CHILD, + standard_output=True, standard_error=True) + + # Start thread to detect fullscreen + self.set_run_threads(self._fullscreen) + # Wrap remaining contructor in try..except to avoid that the child # process is not closed properly. try: @@ -83,19 +103,15 @@ class InputBuffer(object): self._errors = '' # Set non blocking - fcntl.fcntl( - self._process[2], fcntl.F_SETFL, - fcntl.fcntl(self._process[2], fcntl.F_GETFL) | os.O_NONBLOCK) + fcntl.fcntl(self._process[2], fcntl.F_SETFL, + fcntl.fcntl(self._process[2], fcntl.F_GETFL) | os.O_NONBLOCK) # Add watch on child process - GLib.child_watch_add( - GLib.PRIORITY_DEFAULT, self._process[0], self._child_cb) - GLib.io_add_watch( - self._process[2], GLib.PRIORITY_DEFAULT, GLib.IO_IN, - self._child_data_cb, (True, self._input_buffer)) - GLib.io_add_watch( - self._process[3], GLib.PRIORITY_DEFAULT, GLib.IO_IN, - self._child_data_cb, (False, self._error_buffer)) + GLib.child_watch_add(GLib.PRIORITY_DEFAULT, self._process[0], self._child_cb) + GLib.io_add_watch(self._process[2], GLib.PRIORITY_DEFAULT, GLib.IO_IN, + self._child_data_cb, (True, self._input_buffer)) + GLib.io_add_watch(self._process[3], GLib.PRIORITY_DEFAULT, GLib.IO_IN, + self._child_data_cb, (False, self._error_buffer)) # Signal handler to relay USR1 signal to redshift process def relay_signal_handler(signal): @@ -110,26 +126,44 @@ def relay_signal_handler(signal): @property def inhibited(self): - """Current inhibition state.""" + '''Current inhibition state''' return self._inhibited @property def temperature(self): - """Current screen temperature.""" + '''Current screen temperature''' return self._temperature @property def period(self): - """Current period of day.""" + '''Current period of day''' return self._period @property def location(self): - """Current location.""" + '''Current location''' return self._location + @property + def fullscreen(self): + '''Current state of fullscreen detection''' + return self._fullscreen + + @fullscreen.setter + def fullscreen(self, state): + '''Set the fullscreen detection state''' + if self._fullscreen != state: + self._fullscreen == self.set_run_threads(state) + + def set_manually_inhibit(self, inhibit): + '''Set manual inhibition state''' + self._manually_inhibited = inhibit + if self.fullscreen: + self.set_run_threads(not inhibit) + self.set_inhibit(inhibit) + def set_inhibit(self, inhibit): - """Set inhibition state.""" + '''Set inhibition state''' if inhibit != self._inhibited: self._child_toggle_inhibit() @@ -138,11 +172,11 @@ def _child_signal(self, sg): os.kill(self._process[0], sg) def _child_toggle_inhibit(self): - """Sends a request to the child process to toggle state.""" + '''Sends a request to the child process to toggle state''' self._child_signal(signal.SIGUSR1) def _child_cb(self, pid, status, data=None): - """Called when the child process exists.""" + '''Called when the child process exists''' # Empty stdout and stderr for f in (self._process[2], self._process[3]): @@ -150,10 +184,11 @@ def _child_cb(self, pid, status, data=None): buf = os.read(f, 256).decode('utf-8') if buf == '': break - if f == self._process[3]: # stderr + if f == self._process[3]: # stderr self._errors += buf # Check exit status of child + report_errors = False try: GLib.spawn_check_exit_status(status) except GLib.GError: @@ -163,10 +198,10 @@ def _child_cb(self, pid, status, data=None): self.emit('stopped') def _child_key_change_cb(self, key, value): - """Called when the child process reports a change of internal state.""" + '''Called when the child process reports a change of internal state''' def parse_coord(s): - """Parse coordinate like `42.0 N` or `91.5 W`.""" + '''Parse coordinate like `42.0 N` or `91.5 W`''' v, d = s.split(' ') return float(v) * (1 if d in 'NE' else -1) @@ -192,7 +227,7 @@ def parse_coord(s): self.emit('location-changed', *new_location) def _child_stdout_line_cb(self, line): - """Called when the child process outputs a line to stdout.""" + '''Called when the child process outputs a line to stdout''' if line: m = re.match(r'([\w ]+): (.+)', line) if m: @@ -201,7 +236,8 @@ def _child_stdout_line_cb(self, line): self._child_key_change_cb(key, value) def _child_data_cb(self, f, cond, data): - """Called when the child process has new data on stdout/stderr.""" + '''Called when the child process has new data on stdout/stderr''' + stdout, ib = data ib.buf += os.read(f, 256).decode('utf-8') @@ -218,10 +254,62 @@ def _child_data_cb(self, f, cond, data): return True + def set_run_threads(self, state): + '''Set the threads running if state=True or stop them.''' + if state and self._thread is None: + self.start_threads() + elif not state and self._thread is not None: + self.stop_threads() + return state + + def start_threads(self): + '''Start the threads + + Watch asynchronously for the active window getting fullscreen + ''' + if watch_events is None: + self._thread = None + return + + # Initialize the thread + self._thread = watch_events.WatchThread() + + # Connect the thread signals + self._thread.connect("completed", self._register_thread_cancelled) + self._thread.connect("inhibit-triggered", self.set_auto_inhibit) + + # Start thread + self._thread.start() + + def stop_threads(self, block=False): + """Stops all threads. If block is True then actually wait for + the thread to finish (may block the UI) + """ + if self._thread: + self._thread.cancel() + if block: + if self._thread.isAlive(): + self._thread.join() + self._thread = None + + def _register_thread_cancelled(self, thread, state): + '''Relaunch the thread if failed''' + pass + + def set_auto_inhibit(self, thread, state): + '''Set inhibit if the active window change fullscreen state''' + if state != self.inhibited: + if not self._manually_inhibited: + print(_("[{}] Change of fullscreen mode detected, redshift {}.").format(self.__class__.__name__, _('disabled') if state else _('enabled'))) + self.set_inhibit(state) + def terminate_child(self): """Send SIGINT to child process.""" + self.stop_threads() self._child_signal(signal.SIGINT) def kill_child(self): """Send SIGKILL to child process.""" + self.stop_threads() self._child_signal(signal.SIGKILL) + diff --git a/src/redshift-gtk/statusicon.py b/src/redshift-gtk/statusicon.py index 3325403e..bb2f03b8 100644 --- a/src/redshift-gtk/statusicon.py +++ b/src/redshift-gtk/statusicon.py @@ -38,7 +38,7 @@ except (ImportError, ValueError): appindicator = None -from .controller import RedshiftController +from . import controller from . import defs from . import utils @@ -48,10 +48,10 @@ class RedshiftStatusIcon(object): """The status icon tracking the RedshiftController.""" - def __init__(self, controller): + def __init__(self, controller_instance): """Creates a new instance of the status icon.""" - self._controller = controller + self._controller = controller_instance if appindicator: # Create indicator @@ -86,6 +86,14 @@ def __init__(self, controller): suspend_menu_item.set_submenu(suspend_menu) self.status_menu.append(suspend_menu_item) + # Add fullscreen menu + if self._controller.detect_fullscreen: + self.fullscreen_item = Gtk.CheckMenuItem.new_with_label(_('Disable on fullscreen')) + self.fullscreen_item.connect('activate', self.fullscreen_item_cb) + self.status_menu.append(self.fullscreen_item) + else: + self.fullscreen_item = None + # Add autostart option if utils.supports_autostart(): autostart_item = Gtk.CheckMenuItem.new_with_label(_('Autostart')) @@ -154,6 +162,7 @@ def __init__(self, controller): self._controller.connect( 'temperature-changed', self.temperature_change_cb) self._controller.connect('location-changed', self.location_change_cb) + self._controller.connect('fullscreen-changed', self.fullscreen_change_cb) self._controller.connect('error-occured', self.error_occured_cb) self._controller.connect('stopped', self.controller_stopped_cb) @@ -162,6 +171,7 @@ def __init__(self, controller): self.change_period(self._controller.period) self.change_temperature(self._controller.temperature) self.change_location(self._controller.location) + self.change_fullscreen(self._controller.fullscreen) if appindicator: self.status_menu.show_all() @@ -183,6 +193,12 @@ def remove_suspend_timer(self): GLib.source_remove(self.suspend_timer) self.suspend_timer = None + def manually_inhibit(self, inhibit): + '''Callback that handles manual inhibition''' + + # Inhibit + self._controller.set_manually_inhibit(inhibit) + def suspend_cb(self, item, minutes): """Callback that handles activation of a suspend timer. @@ -191,7 +207,7 @@ def suspend_cb(self, item, minutes): and reactive redshift when the timer is up. """ # Inhibit - self._controller.set_inhibit(True) + self.manually_inhibit(True) # If "suspend" is clicked while redshift is disabled, we reenable # it after the last selected timespan is over. @@ -203,7 +219,7 @@ def suspend_cb(self, item, minutes): def reenable_cb(self): """Callback to reenable redshift when a suspend timer expires.""" - self._controller.set_inhibit(False) + self.manually_inhibit(False) def popup_menu_cb(self, widget, button, time, data=None): """Callback when the popup menu on the status icon has to open.""" @@ -214,7 +230,7 @@ def popup_menu_cb(self, widget, button, time, data=None): def toggle_cb(self, widget, data=None): """Callback when a request to toggle redshift was made.""" self.remove_suspend_timer() - self._controller.set_inhibit(not self._controller.inhibited) + self.manually_inhibit(not self._controller.inhibited) def toggle_item_cb(self, widget, data=None): """Callback when a request to toggle redshift was made. @@ -225,7 +241,17 @@ def toggle_item_cb(self, widget, data=None): active = not self._controller.inhibited if active != widget.get_active(): self.remove_suspend_timer() - self._controller.set_inhibit(not self._controller.inhibited) + self.manually_inhibit(not self._controller.inhibited) + + def fullscreen_item_cb(self, widget, data=None): + '''Callback then a request to disable redshift on fullscreen was made from a fullscreen item + + This ensures that the state of redshift is synchronised with + the fullscreen state of the widget (e.g. Gtk.CheckMenuItem).''' + + fullscreen = self._controller.fullscreen + if fullscreen != widget.get_active(): + self._controller.fullscreen = not fullscreen # Info dialog callbacks def show_info_cb(self, widget, data=None): @@ -275,6 +301,10 @@ def location_change_cb(self, controller, lat, lon): """Callback when controlled changes location.""" self.change_location((lat, lon)) + def fullscreen_change_cb(self, controller, state): + '''Callback when controlled changes fullscreen''' + self.change_fullscreen(state) + def error_occured_cb(self, controller, error): """Callback when an error occurs in the controller.""" error_dialog = Gtk.MessageDialog( @@ -317,6 +347,11 @@ def change_location(self, location): self.location_label.set_markup( '{}: {}, {}'.format(_('Location'), *location)) + def change_fullscreen(self, state): + '''Change interface to new fullscreen status''' + if self.fullscreen_item is not None: + self.fullscreen_item.set_active(state) + def update_tooltip_text(self): """Update text of tooltip status icon.""" if not appindicator: @@ -350,7 +385,7 @@ def run(): sys.exit(-1) # Create redshift child process controller - c = RedshiftController(sys.argv[1:]) + c = controller.RedshiftController(sys.argv[1:]) def terminate_child(data=None): c.terminate_child() diff --git a/src/redshift-gtk/watch_events.py b/src/redshift-gtk/watch_events.py new file mode 100644 index 00000000..a0ad15ce --- /dev/null +++ b/src/redshift-gtk/watch_events.py @@ -0,0 +1,120 @@ +# detect-fullscreen.py -- Detect if a window is in fullscreen on the same monitor +# This file is part of Redshift. + +# Redshift 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 3 of the License, or +# (at your option) any later version. + +# Redshift 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 Redshift. If not, see . + +# Copyright (c) 2013-2014 Jon Lund Steffensen + + +'''Detect if a window is in fullscreen + + +''' +import time +import threading +import gi +gi.require_version('Gtk', '3.0') + +from gi.repository import GObject + +from ewmh import EWMH + + +## Threading from https://gist.github.com/nzjrs/51686 +class _IdleObject(GObject.GObject): + """ + Override GObject.GObject to always emit signals in the main thread + by emmitting on an idle handler + """ + def __init__(self): + GObject.GObject.__init__(self) + + def emit(self, *args): + GObject.idle_add(GObject.GObject.emit,self,*args) + + +class WatchThread(threading.Thread, _IdleObject): + """ + Cancellable thread which uses GObject signals to return information + to the GUI. + """ + __gsignals__ = { + "completed": (GObject.SIGNAL_RUN_FIRST, None, (bool,)), + "inhibit-triggered": (GObject.SIGNAL_RUN_FIRST, None, (bool,)), + } + + def __init__(self, delta = 2, fullscreen=None): + threading.Thread.__init__(self) + _IdleObject.__init__(self) + self._ewmh = EWMH() + self.cancelled = False + self.delta = delta + self.fullscreen = fullscreen + self.setName("Detect fullscreen mode") + + def cancel(self): + """ + Threads in python are not cancellable, so we implement our own + cancellation logic + """ + self.cancelled = True + + def detect_fullscreen(self) -> bool: + """Return True if the active window is fullscreen + """ + fullscreen = False + + try: + # Determine if a fullscreen application is running + window = self._ewmh.getActiveWindow() + # ewmh.getWmState(window) returns None is scenarios where + # ewmh.getWmState(window, str=True) throws an exception + # (it's a bug in pyewmh): + if window and self._ewmh.getWmState(window): + list_states = self._ewmh.getWmState(window, True) + fullscreen = "_NET_WM_STATE_FULLSCREEN" in list_states + except Exception as e: + print("Error ignored:\n", e) + return None + return fullscreen + + def run(self): + """Watch if the fullscreen mode is detected + + Wait `delta` seconds between each check. It can be cancelled by + changing the `cancelled` attribute. The `inhibit-triggered` + signal is emitted when the state is changed, holding the value + True if the active window has become fullscreen, False if it has + quit the fullscreen mode. + """ + # Init the fullscreen state if it was not user-defined + if self.fullscreen is None: + self.fullscreen = self.detect_fullscreen() + + # Continuously detect changes in fullscreen mode + try: + while(not self.cancelled): + f = self.detect_fullscreen() + + if f is not None and f != self.fullscreen: + self.fullscreen = f + self.emit("inhibit-triggered", self.fullscreen) + time.sleep(self.delta) + except Exception as e: + raise + self.emit("completed", False) + else: + self.emit("completed", True) + +