1# Copyright (c) 2008-2010 Aldo Cortesi
2# Copyright (c) 2011 Florian Mounier
3# Copyright (c) 2011 Kenji_Takahashi
4# Copyright (c) 2011 Paul Colomiets
5# Copyright (c) 2012 roger
6# Copyright (c) 2012 Craig Barnes
7# Copyright (c) 2012-2015 Tycho Andersen
8# Copyright (c) 2013 dequis
9# Copyright (c) 2013 David R. Andersen
10# Copyright (c) 2013 Tao Sauvage
11# Copyright (c) 2014-2015 Sean Vig
12# Copyright (c) 2014 Justin Bronder
13#
14# Permission is hereby granted, free of charge, to any person obtaining a copy
15# of this software and associated documentation files (the "Software"), to deal
16# in the Software without restriction, including without limitation the rights
17# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18# copies of the Software, and to permit persons to whom the Software is
19# furnished to do so, subject to the following conditions:
20#
21# The above copyright notice and this permission notice shall be included in
22# all copies or substantial portions of the Software.
23#
24# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30# SOFTWARE.
31
32import asyncio
33import copy
34import subprocess
35from typing import Any, List, Tuple
36
37from libqtile import bar, configurable, confreader
38from libqtile.command.base import CommandError, CommandObject, ItemT
39from libqtile.log_utils import logger
40
41
42# Each widget class must define which bar orientation(s) it supports by setting
43# these bits in an 'orientations' class attribute. Simply having the attribute
44# inherited by superclasses is discouraged, because if a superclass that was
45# only supporting one orientation, adds support for the other, its subclasses
46# will have to be adapted too, in general. ORIENTATION_NONE is only added for
47# completeness' sake.
48# +------------------------+--------------------+--------------------+
49# | Widget bits            | Horizontal bar     | Vertical bar       |
50# +========================+====================+====================+
51# | ORIENTATION_NONE       | ConfigError raised | ConfigError raised |
52# +------------------------+--------------------+--------------------+
53# | ORIENTATION_HORIZONTAL | Widget displayed   | ConfigError raised |
54# |                        | horizontally       |                    |
55# +------------------------+--------------------+--------------------+
56# | ORIENTATION_VERTICAL   | ConfigError raised | Widget displayed   |
57# |                        |                    | vertically         |
58# +------------------------+--------------------+--------------------+
59# | ORIENTATION_BOTH       | Widget displayed   | Widget displayed   |
60# |                        | horizontally       | vertically         |
61# +------------------------+--------------------+--------------------+
62class _Orientations(int):
63    def __new__(cls, value, doc):
64        return super().__new__(cls, value)
65
66    def __init__(self, value, doc):
67        self.doc = doc
68
69    def __str__(self):
70        return self.doc
71
72    def __repr__(self):
73        return self.doc
74
75
76ORIENTATION_NONE = _Orientations(0, 'none')
77ORIENTATION_HORIZONTAL = _Orientations(1, 'horizontal only')
78ORIENTATION_VERTICAL = _Orientations(2, 'vertical only')
79ORIENTATION_BOTH = _Orientations(3, 'horizontal and vertical')
80
81
82class _Widget(CommandObject, configurable.Configurable):
83    """Base Widget class
84
85    If length is set to the special value `bar.STRETCH`, the bar itself will
86    set the length to the maximum remaining space, after all other widgets have
87    been configured.
88
89    In horizontal bars, 'length' corresponds to the width of the widget; in
90    vertical bars, it corresponds to the widget's height.
91
92    The offsetx and offsety attributes are set by the Bar after all widgets
93    have been configured.
94
95    Callback functions can be assigned to button presses by passing a dict to the
96    'callbacks' kwarg. No arguments are passed to the callback function so, if
97    you need access to the qtile object, it needs to be imported into your code.
98
99    For example:
100
101    .. code-block:: python
102
103        from libqtile import qtile
104
105        def open_calendar():
106            qtile.cmd_spawn('gsimplecal next_month')
107
108        clock = widget.Clock(mouse_callbacks={'Button1': open_calendar})
109
110    When the clock widget receives a click with button 1, the ``open_calendar`` function
111    will be executed. Callbacks can be assigned to other buttons by adding more entries
112    to the passed dictionary.
113    """
114    orientations = ORIENTATION_BOTH
115    offsetx: int = 0
116    offsety: int = 0
117    defaults = [
118        ("background", None, "Widget background color"),
119        ("mouse_callbacks", {}, "Dict of mouse button press callback functions."),
120    ]  # type: List[Tuple[str, Any, str]]
121
122    def __init__(self, length, **config):
123        """
124            length: bar.STRETCH, bar.CALCULATED, or a specified length.
125        """
126        CommandObject.__init__(self)
127        self.name = self.__class__.__name__.lower()
128        if "name" in config:
129            self.name = config["name"]
130
131        configurable.Configurable.__init__(self, **config)
132        self.add_defaults(_Widget.defaults)
133
134        if length in (bar.CALCULATED, bar.STRETCH):
135            self.length_type = length
136            self.length = 0
137        else:
138            assert isinstance(length, int)
139            self.length_type = bar.STATIC
140            self.length = length
141        self.configured = False
142
143    @property
144    def length(self):
145        if self.length_type == bar.CALCULATED:
146            return int(self.calculate_length())
147        return self._length
148
149    @length.setter
150    def length(self, value):
151        self._length = value
152
153    @property
154    def width(self):
155        if self.bar.horizontal:
156            return self.length
157        return self.bar.size
158
159    @property
160    def height(self):
161        if self.bar.horizontal:
162            return self.bar.size
163        return self.length
164
165    @property
166    def offset(self):
167        if self.bar.horizontal:
168            return self.offsetx
169        return self.offsety
170
171    # Do not start the name with "test", or nosetests will try to test it
172    # directly (prepend an underscore instead)
173    def _test_orientation_compatibility(self, horizontal):
174        if horizontal:
175            if not self.orientations & ORIENTATION_HORIZONTAL:
176                raise confreader.ConfigError(
177                    self.__class__.__name__ +
178                    " is not compatible with the orientation of the bar."
179                )
180        elif not self.orientations & ORIENTATION_VERTICAL:
181            raise confreader.ConfigError(
182                self.__class__.__name__ +
183                " is not compatible with the orientation of the bar."
184            )
185
186    def timer_setup(self):
187        """ This is called exactly once, after the widget has been configured
188        and timers are available to be set up. """
189        pass
190
191    def _configure(self, qtile, bar):
192        self.qtile = qtile
193        self.bar = bar
194        self.drawer = bar.window.create_drawer(self.bar.width, self.bar.height)
195        if not self.configured:
196            self.qtile.call_soon(self.timer_setup)
197            self.qtile.call_soon(asyncio.create_task, self._config_async())
198
199    async def _config_async(self):
200        """
201            This is called once when the main eventloop has started. this
202            happens after _configure has been run.
203
204            Widgets that need to use asyncio coroutines after this point may
205            wish to initialise the relevant code (e.g. connections to dbus
206            using dbus_next) here.
207        """
208        pass
209
210    def finalize(self):
211        if hasattr(self, 'layout') and self.layout:
212            self.layout.finalize()
213        self.drawer.finalize()
214
215    def clear(self):
216        self.drawer.set_source_rgb(self.bar.background)
217        self.drawer.fillrect(self.offsetx, self.offsety, self.width,
218                             self.height)
219
220    def info(self):
221        return dict(
222            name=self.name,
223            offset=self.offset,
224            length=self.length,
225            width=self.width,
226            height=self.height,
227        )
228
229    def add_callbacks(self, defaults):
230        """Add default callbacks with a lower priority than user-specified callbacks."""
231        defaults.update(self.mouse_callbacks)
232        self.mouse_callbacks = defaults
233
234    def button_press(self, x, y, button):
235        name = 'Button{0}'.format(button)
236        if name in self.mouse_callbacks:
237            self.mouse_callbacks[name]()
238
239    def button_release(self, x, y, button):
240        pass
241
242    def get(self, q, name):
243        """
244            Utility function for quick retrieval of a widget by name.
245        """
246        w = q.widgets_map.get(name)
247        if not w:
248            raise CommandError("No such widget: %s" % name)
249        return w
250
251    def _items(self, name: str) -> ItemT:
252        if name == "bar":
253            return True, []
254        elif name == "screen":
255            return True, []
256        return None
257
258    def _select(self, name, sel):
259        if name == "bar":
260            return self.bar
261        elif name == "screen":
262            return self.bar.screen
263
264    def cmd_info(self):
265        """
266            Info for this object.
267        """
268        return self.info()
269
270    def draw(self):
271        """
272            Method that draws the widget. You may call this explicitly to
273            redraw the widget, but only if the length of the widget hasn't
274            changed. If it has, you must call bar.draw instead.
275        """
276        raise NotImplementedError
277
278    def calculate_length(self):
279        """
280            Must be implemented if the widget can take CALCULATED for length.
281            It must return the width of the widget if it's installed in a
282            horizontal bar; it must return the height of the widget if it's
283            installed in a vertical bar. Usually you will test the orientation
284            of the bar with 'self.bar.horizontal'.
285        """
286        raise NotImplementedError
287
288    def timeout_add(self, seconds, method, method_args=()):
289        """
290            This method calls either ``.call_later`` with given arguments.
291        """
292        return self.qtile.call_later(seconds, self._wrapper, method,
293                                     *method_args)
294
295    def call_process(self, command, **kwargs):
296        """
297            This method uses `subprocess.check_output` to run the given command
298            and return the string from stdout, which is decoded when using
299            Python 3.
300        """
301        return subprocess.check_output(command, **kwargs, encoding="utf-8")
302
303    def _wrapper(self, method, *method_args):
304        try:
305            method(*method_args)
306        except:  # noqa: E722
307            logger.exception('got exception from widget timer')
308
309    def create_mirror(self):
310        return Mirror(self, background=self.background)
311
312    def clone(self):
313        return copy.copy(self)
314
315    def mouse_enter(self, x, y):
316        pass
317
318    def mouse_leave(self, x, y):
319        pass
320
321
322UNSPECIFIED = bar.Obj("UNSPECIFIED")
323
324
325class _TextBox(_Widget):
326    """
327        Base class for widgets that are just boxes containing text.
328    """
329    orientations = ORIENTATION_HORIZONTAL
330    defaults = [
331        ("font", "sans", "Default font"),
332        ("fontsize", None, "Font size. Calculated if None."),
333        ("padding", None, "Padding. Calculated if None."),
334        ("foreground", "ffffff", "Foreground colour"),
335        (
336            "fontshadow",
337            None,
338            "font shadow color, default is None(no shadow)"
339        ),
340        ("markup", True, "Whether or not to use pango markup"),
341        ("fmt", "{}", "How to format the text"),
342        ('max_chars', 0, 'Maximum number of characters to display in widget.'),
343    ]  # type: List[Tuple[str, Any, str]]
344
345    def __init__(self, text=" ", width=bar.CALCULATED, **config):
346        self.layout = None
347        _Widget.__init__(self, width, **config)
348        self._text = text
349        self.add_defaults(_TextBox.defaults)
350
351    @property
352    def text(self):
353        return self._text
354
355    @text.setter
356    def text(self, value):
357        if len(value) > self.max_chars > 0:
358            value = value[:self.max_chars] + "…"
359        self._text = value
360        if self.layout:
361            self.layout.text = self.formatted_text
362
363    @property
364    def formatted_text(self):
365        return self.fmt.format(self._text)
366
367    @property
368    def foreground(self):
369        return self._foreground
370
371    @foreground.setter
372    def foreground(self, fg):
373        self._foreground = fg
374        if self.layout:
375            self.layout.colour = fg
376
377    @property
378    def font(self):
379        return self._font
380
381    @font.setter
382    def font(self, value):
383        self._font = value
384        if self.layout:
385            self.layout.font = value
386
387    @property
388    def fontshadow(self):
389        return self._fontshadow
390
391    @fontshadow.setter
392    def fontshadow(self, value):
393        self._fontshadow = value
394        if self.layout:
395            self.layout.font_shadow = value
396
397    @property
398    def actual_padding(self):
399        if self.padding is None:
400            return self.fontsize / 2
401        else:
402            return self.padding
403
404    def _configure(self, qtile, bar):
405        _Widget._configure(self, qtile, bar)
406        if self.fontsize is None:
407            self.fontsize = self.bar.height - self.bar.height / 5
408        self.layout = self.drawer.textlayout(
409            self.formatted_text,
410            self.foreground,
411            self.font,
412            self.fontsize,
413            self.fontshadow,
414            markup=self.markup,
415        )
416
417    def calculate_length(self):
418        if self.text:
419            return min(
420                self.layout.width,
421                self.bar.width
422            ) + self.actual_padding * 2
423        else:
424            return 0
425
426    def can_draw(self):
427        can_draw = self.layout is not None \
428                and not self.layout.finalized() \
429                and self.offsetx is not None  # if the bar hasn't placed us yet
430        return can_draw
431
432    def draw(self):
433        if not self.can_draw():
434            return
435        self.drawer.clear(self.background or self.bar.background)
436        self.layout.draw(
437            self.actual_padding or 0,
438            int(self.bar.height / 2.0 - self.layout.height / 2.0) + 1
439        )
440        self.drawer.draw(offsetx=self.offsetx, width=self.width)
441
442    def cmd_set_font(self, font=UNSPECIFIED, fontsize=UNSPECIFIED,
443                     fontshadow=UNSPECIFIED):
444        """
445            Change the font used by this widget. If font is None, the current
446            font is used.
447        """
448        if font is not UNSPECIFIED:
449            self.font = font
450        if fontsize is not UNSPECIFIED:
451            self.fontsize = fontsize
452        if fontshadow is not UNSPECIFIED:
453            self.fontshadow = fontshadow
454        self.bar.draw()
455
456    def info(self):
457        d = _Widget.info(self)
458        d['foreground'] = self.foreground
459        d['text'] = self.formatted_text
460        return d
461
462    def update(self, text):
463        if self.text == text:
464            return
465        if text is None:
466            text = ""
467
468        old_width = self.layout.width
469        self.text = text
470
471        # If our width hasn't changed, we just draw ourselves. Otherwise,
472        # we draw the whole bar.
473        if self.layout.width == old_width:
474            self.draw()
475        else:
476            self.bar.draw()
477
478
479class InLoopPollText(_TextBox):
480    """ A common interface for polling some 'fast' information, munging it, and
481    rendering the result in a text box. You probably want to use
482    ThreadPoolText instead.
483
484    ('fast' here means that this runs /in/ the event loop, so don't block! If
485    you want to run something nontrivial, use ThreadedPollWidget.) """
486
487    defaults = [
488        ("update_interval", 600, "Update interval in seconds, if none, the "
489            "widget updates whenever the event loop is idle."),
490    ]  # type: List[Tuple[str, Any, str]]
491
492    def __init__(self, default_text="N/A", width=bar.CALCULATED, **config):
493        _TextBox.__init__(self, default_text, width, **config)
494        self.add_defaults(InLoopPollText.defaults)
495
496    def timer_setup(self):
497        update_interval = self.tick()
498        # If self.update_interval is defined and .tick() returns None, re-call
499        # after self.update_interval
500        if update_interval is None and self.update_interval is not None:
501            self.timeout_add(self.update_interval, self.timer_setup)
502        # We can change the update interval by returning something from .tick()
503        elif update_interval:
504            self.timeout_add(update_interval, self.timer_setup)
505        # If update_interval is False, we won't re-call
506
507    def _configure(self, qtile, bar):
508        should_tick = self.configured
509        _TextBox._configure(self, qtile, bar)
510
511        # Update when we are being re-configured.
512        if should_tick:
513            self.tick()
514
515    def button_press(self, x, y, button):
516        self.tick()
517        _TextBox.button_press(self, x, y, button)
518
519    def poll(self):
520        return 'N/A'
521
522    def tick(self):
523        text = self.poll()
524        self.update(text)
525
526
527class ThreadPoolText(_TextBox):
528    """ A common interface for wrapping blocking events which when triggered
529    will update a textbox.
530
531    The poll method is intended to wrap a blocking function which may take
532    quite a while to return anything.  It will be executed as a future and
533    should return updated text when completed.  It may also return None to
534    disable any further updates.
535
536    param: text - Initial text to display.
537    """
538    defaults = [
539        ("update_interval", 600, "Update interval in seconds, if none, the "
540            "widget updates whenever it's done'."),
541    ]  # type: List[Tuple[str, Any, str]]
542
543    def __init__(self, text, **config):
544        super().__init__(text, width=bar.CALCULATED, **config)
545        self.add_defaults(ThreadPoolText.defaults)
546
547    def timer_setup(self):
548        def on_done(future):
549            try:
550                result = future.result()
551            except Exception:
552                result = None
553                logger.exception('poll() raised exceptions, not rescheduling')
554
555            if result is not None:
556                try:
557                    self.update(result)
558
559                    if self.update_interval is not None:
560                        self.timeout_add(self.update_interval, self.timer_setup)
561                    else:
562                        self.timer_setup()
563
564                except Exception:
565                    logger.exception('Failed to reschedule.')
566            else:
567                logger.warning('poll() returned None, not rescheduling')
568
569        future = self.qtile.run_in_executor(self.poll)
570        future.add_done_callback(on_done)
571
572    def poll(self):
573        pass
574
575# these two classes below look SUSPICIOUSLY similar
576
577
578class PaddingMixin(configurable.Configurable):
579    """Mixin that provides padding(_x|_y|)
580
581    To use it, subclass and add this to __init__:
582
583        self.add_defaults(base.PaddingMixin.defaults)
584    """
585
586    defaults = [
587        ("padding", 3, "Padding inside the box"),
588        ("padding_x", None, "X Padding. Overrides 'padding' if set"),
589        ("padding_y", None, "Y Padding. Overrides 'padding' if set"),
590    ]  # type: List[Tuple[str, Any, str]]
591
592    padding_x = configurable.ExtraFallback('padding_x', 'padding')
593    padding_y = configurable.ExtraFallback('padding_y', 'padding')
594
595
596class MarginMixin(configurable.Configurable):
597    """Mixin that provides margin(_x|_y|)
598
599    To use it, subclass and add this to __init__:
600
601        self.add_defaults(base.MarginMixin.defaults)
602    """
603
604    defaults = [
605        ("margin", 3, "Margin inside the box"),
606        ("margin_x", None, "X Margin. Overrides 'margin' if set"),
607        ("margin_y", None, "Y Margin. Overrides 'margin' if set"),
608    ]  # type: List[Tuple[str, Any, str]]
609
610    margin_x = configurable.ExtraFallback('margin_x', 'margin')
611    margin_y = configurable.ExtraFallback('margin_y', 'margin')
612
613
614class Mirror(_Widget):
615    """
616    A widget for showing the same widget content in more than one place, for
617    instance, on bars across multiple screens.
618
619    You don't need to use it directly; instead, just instantiate your widget
620    once and hand it in to multiple bars. For instance::
621
622        cpu = widget.CPUGraph()
623        clock = widget.Clock()
624
625        screens = [
626            Screen(top=bar.Bar([widget.GroupBox(), cpu, clock])),
627            Screen(top=bar.Bar([widget.GroupBox(), cpu, clock])),
628        ]
629
630    Widgets can be passed to more than one bar, so that there don't need to be
631    any duplicates executing the same code all the time, and they'll always be
632    visually identical.
633
634    This works for all widgets that use `drawers` (and nothing else) to display
635    their contents. Currently, this is all widgets except for `Systray`.
636    """
637
638    def __init__(self, reflection, **config):
639        _Widget.__init__(self, reflection.length, **config)
640        reflection.draw = self.hook(reflection.draw)
641        self.reflects = reflection
642        self._length = 0
643
644    def _configure(self, qtile, bar):
645        _Widget._configure(self, qtile, bar)
646        self.reflects.drawer.add_mirror(self.drawer)
647        # We need to fill the background once before `draw` is called so, if
648        # there's no reflection, the mirror matches its parent bar.
649        self.drawer.clear(self.background or self.bar.background)
650
651    @property
652    def length(self):
653        return self.reflects.length
654
655    @length.setter
656    def length(self, value):
657        self._length = value
658
659    def hook(self, draw):
660        def _():
661            draw()
662            self.draw()
663        return _
664
665    def draw(self):
666        if self._length != self.reflects.length:
667            self._length = self.length
668            self.bar.draw()
669        else:
670            # We only update the mirror's drawer if the parent widget has
671            # contents in its RecordingSurface. If this is False then the widget
672            # wil just show the existing drawer contents.
673            if self.reflects.drawer.needs_update:
674                self.drawer.clear(self.background or self.bar.background)
675                self.reflects.drawer.paint_to(self.drawer)
676            self.drawer.draw(offsetx=self.offset, width=self.width)
677
678    def button_press(self, x, y, button):
679        self.reflects.button_press(x, y, button)
680