graph.py

  1#!/usr/bin/env python
  2#
  3# Urwid graphics example program
  4#    Copyright (C) 2004-2011  Ian Ward
  5#
  6#    This library is free software; you can redistribute it and/or
  7#    modify it under the terms of the GNU Lesser General Public
  8#    License as published by the Free Software Foundation; either
  9#    version 2.1 of the License, or (at your option) any later version.
 10#
 11#    This library is distributed in the hope that it will be useful,
 12#    but WITHOUT ANY WARRANTY; without even the implied warranty of
 13#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 14#    Lesser General Public License for more details.
 15#
 16#    You should have received a copy of the GNU Lesser General Public
 17#    License along with this library; if not, write to the Free Software
 18#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 19#
 20# Urwid web site: https://urwid.org/
 21
 22"""Urwid example demonstrating use of the BarGraph widget and creating a floating-window appearance.
 23
 24Also shows use of alarms to create timed animation.
 25"""
 26
 27from __future__ import annotations
 28
 29import math
 30import time
 31import typing
 32
 33import urwid
 34
 35if typing.TYPE_CHECKING:
 36    from collections.abc import Callable, Sequence
 37
 38UPDATE_INTERVAL = 0.2
 39
 40
 41def sin100(x: int) -> float:
 42    """Return a sine value scaled to the range 0-100 that repeats every 100 steps.
 43
 44    :param x: input step
 45    :returns: sine wave value in the range 0-100
 46    """
 47    return 50 + 50 * math.sin(x * math.pi / 50)
 48
 49
 50class GraphModel:
 51    """Store the data that will be displayed on the graph, and keep track of which mode is enabled."""
 52
 53    data_max_value: typing.ClassVar[int] = 100
 54
 55    def __init__(self) -> None:
 56        """Initialize with the built-in sample data sets, defaulting to the first mode."""
 57        data: list[tuple[str, Sequence[float | int]]] = [
 58            ("Saw", list(range(0, 100, 2)) * 2),
 59            ("Square", [0] * 30 + [100] * 30),
 60            ("Sine 1", [sin100(x) for x in range(100)]),
 61            ("Sine 2", [(sin100(x) + sin100(x * 2)) / 2 for x in range(100)]),
 62            ("Sine 3", [(sin100(x) + sin100(x * 3)) / 2 for x in range(100)]),
 63        ]
 64        self.modes: list[str] = []
 65        self.data: dict[str, Sequence[float | int]] = {}
 66        for m, d in data:
 67            self.modes.append(m)
 68            self.data[m] = d
 69        self.current_mode = self.modes[0]
 70
 71    def get_modes(self) -> list[str]:
 72        """Return the available mode names."""
 73        return self.modes
 74
 75    def set_mode(self, m: str) -> None:
 76        """Set the current mode.
 77
 78        :param m: name of the mode to switch to
 79        """
 80        self.current_mode = m
 81
 82    def get_data(self, offset: int, r: int) -> tuple[list[float | int], int, int]:
 83        """Return a slice of the current mode's data, its maximum value, and the length it repeats at.
 84
 85        :param offset: start offset into the data, wrapped to the data length
 86        :param r: number of data points to return
 87        :returns: the requested data points, the maximum value among all items, and the length the data repeats at
 88        """
 89        lines: list[float | int] = []
 90        d = self.data[self.current_mode]
 91        while r:
 92            offset %= len(d)
 93            segment = d[offset : offset + r]
 94            r -= len(segment)
 95            offset += len(segment)
 96            lines += segment
 97        return lines, self.data_max_value, len(d)
 98
 99
100class GraphView(urwid.WidgetWrap[urwid.Widget]):
101    """Provide the application's interface and graph display."""
102
103    palette: typing.ClassVar[list[tuple[str, str, str] | tuple[str, str, str, str]]] = [
104        ("body", "black", "light gray", "standout"),
105        ("header", "white", "dark red", "bold"),
106        ("screen edge", "light blue", "dark cyan"),
107        ("main shadow", "dark gray", "black"),
108        ("line", "black", "light gray", "standout"),
109        ("bg background", "light gray", "black"),
110        ("bg 1", "black", "dark blue", "standout"),
111        ("bg 1 smooth", "dark blue", "black"),
112        ("bg 2", "black", "dark cyan", "standout"),
113        ("bg 2 smooth", "dark cyan", "black"),
114        ("button normal", "light gray", "dark blue", "standout"),
115        ("button select", "white", "dark green"),
116        ("line", "black", "light gray", "standout"),
117        ("pg normal", "white", "black", "standout"),
118        ("pg complete", "white", "dark magenta"),
119        ("pg smooth", "dark magenta", "black"),
120    ]
121
122    graph_samples_per_bar: typing.ClassVar[int] = 10
123    graph_num_bars: typing.ClassVar[int] = 5
124    graph_offset_per_second: typing.ClassVar[int] = 5
125
126    def __init__(self, controller: GraphController) -> None:
127        """Initialize the graph display, stopped and not yet timed, wrapping `controller`."""
128        self.controller = controller
129        self.started = True
130        self.start_time: float | None = None
131        self.offset = 0
132        self.last_offset: int | None = None
133        super().__init__(self.main_window())
134
135    def get_offset_now(self) -> int:
136        """Return the current graph offset, accounting for elapsed animation time.
137
138        :returns: graph offset
139        """
140        if self.start_time is None:
141            return 0
142        if not self.started:
143            return self.offset
144        tdelta = time.time() - self.start_time
145        return int(self.offset + (tdelta * self.graph_offset_per_second))
146
147    def update_graph(self, force_update: bool = False) -> bool:
148        """Recompute the graph and progress bar for the current offset.
149
150        :param force_update: redraw even if the offset has not changed since the last update
151        :returns: whether the graph was actually redrawn
152        """
153        o = self.get_offset_now()
154        if o == self.last_offset and not force_update:
155            return False
156        self.last_offset = o
157        gspb = self.graph_samples_per_bar
158        r = gspb * self.graph_num_bars
159        d, max_value, repeat = self.controller.get_data(o, r)
160        lines: list[list[float | int]] = []
161        for n in range(self.graph_num_bars):
162            value = sum(d[n * gspb : (n + 1) * gspb]) / gspb
163            # toggle between two bar types
164            if n & 1:
165                lines.append([0, value])
166            else:
167                lines.append([value, 0])
168        self.graph.set_data(lines, max_value)
169
170        # also update progress
171        prog: float
172        if (o // repeat) & 1:
173            # show 100% for first half, 0 for second half
174            if o % repeat > repeat // 2:
175                prog = 0
176            else:
177                prog = 1
178        else:
179            prog = float(o % repeat) / repeat
180        self.animate_progress.current = prog
181        return True
182
183    def on_animate_button(self, button: urwid.Button) -> None:
184        """Toggle started state and button text.
185
186        :param button: button that was pressed
187        """
188        if self.started:  # stop animation
189            button.set_label("Start")
190            self.offset = self.get_offset_now()
191            self.started = False
192            self.controller.stop_animation()
193        else:
194            button.set_label("Stop")
195            self.started = True
196            self.start_time = time.time()
197            self.controller.animate_graph()
198
199    def on_reset_button(self, w: urwid.Button) -> None:
200        """Reset the animation offset and restart the clock.
201
202        :param w: button that was pressed
203        """
204        self.offset = 0
205        self.start_time = time.time()
206        self.update_graph(True)
207
208    def on_mode_button(self, button: urwid.RadioButton, state: bool) -> None:
209        """Notify the controller of a new mode setting.
210
211        :param button: radio button whose state changed, carrying the mode name as its label
212        :param state: new state of the radio button
213        """
214        if state:
215            # The new mode is the label of the button
216            self.controller.set_mode(typing.cast("str", button.get_label()))
217        self.last_offset = None
218
219    def on_mode_change(self, m: str) -> None:
220        """Handle external mode change by updating radio buttons.
221
222        :param m: name of the mode that is now selected
223        """
224        for rb in self.mode_buttons:
225            if rb.original_widget.label == m:
226                rb.original_widget.set_state(True, do_callback=False)
227                break
228        self.last_offset = None
229
230    def on_unicode_checkbox(self, w: urwid.CheckBox, state: bool) -> None:
231        """Switch the graph and progress bar between Unicode and ASCII rendering.
232
233        :param w: checkbox that changed
234        :param state: new state of the checkbox
235        """
236        self.graph = self.bar_graph(state)
237        self.graph_wrap._w = self.graph
238        self.animate_progress = self.progress_bar(state)
239        self.animate_progress_wrap._w = self.animate_progress
240        self.update_graph(True)
241
242    def main_shadow(self, w: urwid.Widget) -> urwid.Widget:
243        """Wrap a shadow and background around a widget.
244
245        :param w: widget to wrap
246        :returns: the wrapped widget
247        """
248        bg: urwid.Widget = urwid.AttrMap(urwid.SolidFill("▒"), "screen edge")
249        shadow = urwid.AttrMap(urwid.SolidFill(" "), "main shadow")
250
251        bg = urwid.Overlay(
252            shadow,
253            bg,
254            align=urwid.LEFT,
255            width=urwid.RELATIVE_100,
256            valign=urwid.TOP,
257            height=urwid.RELATIVE_100,
258            left=3,
259            right=1,
260            top=2,
261            bottom=1,
262        )
263        w = urwid.Overlay(
264            w,
265            bg,
266            align=urwid.LEFT,
267            width=urwid.RELATIVE_100,
268            valign=urwid.TOP,
269            height=urwid.RELATIVE_100,
270            left=2,
271            right=3,
272            top=1,
273            bottom=2,
274        )
275        return w
276
277    def bar_graph(self, smooth: bool = False) -> urwid.BarGraph:
278        """Create the bar graph widget.
279
280        :param smooth: use smoothed (sub-character resolution) bars
281        :returns: the bar graph widget
282        """
283        satt = None
284        if smooth:
285            satt = {(1, 0): "bg 1 smooth", (2, 0): "bg 2 smooth"}
286        w = urwid.BarGraph(["bg background", "bg 1", "bg 2"], satt=satt)
287        return w
288
289    def button(
290        self,
291        t: str,
292        fn: Callable[[urwid.Button], None],
293    ) -> urwid.AttrMap[urwid.Button]:
294        """Create a styled push button.
295
296        :param t: button label
297        :param fn: callback connected to the button's ``click`` signal
298        :returns: the button wrapped in an :class:`urwid.AttrMap`
299        """
300        w = urwid.Button(t, fn)
301        return urwid.AttrMap(w, "button normal", "button select")
302
303    def radio_button(
304        self,
305        g: list[urwid.RadioButton],
306        label: str,
307        fn: Callable[[urwid.RadioButton, bool], None],
308    ) -> urwid.AttrMap[urwid.RadioButton]:
309        """Create a styled radio button for selecting a mode.
310
311        :param g: radio button group to add the new button to
312        :param label: button label, also used as the mode name
313        :param fn: callback connected to the button's ``change`` signal
314        :returns: the radio button wrapped in an :class:`urwid.AttrMap`
315        """
316        w = urwid.RadioButton(g, label, False, on_state_change=fn)
317        return urwid.AttrMap(w, "button normal", "button select")
318
319    def progress_bar(self, smooth: bool = False) -> urwid.ProgressBar:
320        """Create the animation progress bar widget.
321
322        :param smooth: use smoothed (sub-character resolution) rendering
323        :returns: the progress bar widget
324        """
325        return urwid.ProgressBar(
326            "pg normal",
327            "pg complete",
328            0,
329            1,
330            "pg smooth" if smooth else None,
331        )
332
333    def exit_program(self, w: urwid.Button) -> typing.NoReturn:
334        """Exit the main loop.
335
336        :param w: button that was pressed
337        :raises urwid.ExitMainLoop: always
338        """
339        raise urwid.ExitMainLoop
340
341    def graph_controls(self) -> urwid.ListBox[int]:
342        """Build the list of mode, animation and quit controls shown beside the graph.
343
344        :returns: the controls list box
345        """
346        modes = self.controller.get_modes()
347        # setup mode radio buttons
348        self.mode_buttons: list[urwid.AttrMap[urwid.RadioButton]] = []
349        group: list[urwid.RadioButton] = []
350        for m in modes:
351            rb = self.radio_button(group, m, self.on_mode_button)
352            self.mode_buttons.append(rb)
353        # setup animate button
354        self.animate_button = self.button("", self.on_animate_button)
355        self.on_animate_button(self.animate_button.original_widget)
356        self.offset = 0
357        self.animate_progress = self.progress_bar()
358        animate_controls = urwid.GridFlow(
359            [
360                self.animate_button,
361                self.button("Reset", self.on_reset_button),
362            ],
363            9,
364            2,
365            0,
366            urwid.CENTER,
367            select_navigation=True,
368        )
369
370        unicode_checkbox: urwid.Widget
371        if urwid.get_encoding_mode() == "utf8":
372            unicode_checkbox = urwid.CheckBox("Enable Unicode Graphics", on_state_change=self.on_unicode_checkbox)
373        else:
374            unicode_checkbox = urwid.Text("UTF-8 encoding not detected")
375
376        self.animate_progress_wrap = urwid.WidgetWrap(self.animate_progress)
377
378        lines: list[urwid.Widget] = [
379            urwid.Text("Mode", align=urwid.CENTER),
380            *self.mode_buttons,
381            urwid.Divider(),
382            urwid.Text("Animation", align=urwid.CENTER),
383            animate_controls,
384            self.animate_progress_wrap,
385            urwid.Divider(),
386            urwid.LineBox(unicode_checkbox),
387            urwid.Divider(),
388            self.button("Quit", self.exit_program),
389        ]
390        # select_navigation lets TAB / SHIFT TAB move between the controls
391        walker = urwid.SimpleListWalker(lines)
392        w = urwid.ListBox(walker, select_navigation=True)  # type: ignore[arg-type]  # all lines are flow widgets
393        return w
394
395    def main_window(self) -> urwid.Widget:
396        """Build the complete application window.
397
398        :returns: the top-level widget
399        """
400        self.graph = self.bar_graph()
401        self.graph_wrap = urwid.WidgetWrap(self.graph)
402        vline = urwid.AttrMap(urwid.SolidFill("│"), "line")
403        c = self.graph_controls()
404        w: urwid.Widget = urwid.Columns(
405            [(urwid.WEIGHT, 2, self.graph_wrap), (1, vline), c],
406            dividechars=1,
407            focus_column=2,
408        )
409        w = urwid.Padding(w, urwid.LEFT, left=1)
410        w = urwid.AttrMap(w, "body")
411        w = urwid.LineBox(w)
412        w = urwid.AttrMap(w, "line")
413        w = self.main_shadow(w)
414        return w
415
416
417class GraphController:
418    """Set up the model and view and run the application."""
419
420    def __init__(self) -> None:
421        """Initialize the model and view and switch to the first available mode."""
422        self.animate_alarm: typing.Any = None
423        self.model = GraphModel()
424        self.view = GraphView(self)
425        # use the first mode as the default
426        mode = self.get_modes()[0]
427        self.model.set_mode(mode)
428        # update the view
429        self.view.on_mode_change(mode)
430        self.view.update_graph(True)
431
432    def get_modes(self) -> list[str]:
433        """Allow our view access to the list of modes."""
434        return self.model.get_modes()
435
436    def set_mode(self, m: str) -> None:
437        """Allow our view to set the mode."""
438        self.model.set_mode(m)
439        self.view.update_graph(True)
440
441    def get_data(self, offset: int, data_range: int) -> tuple[list[float | int], int, int]:
442        """Provide data to our view for the graph.
443
444        :param offset: start offset into the data
445        :param data_range: number of data points to return
446        :returns: the requested data points, their maximum value, and the length the data repeats at
447        """
448        return self.model.get_data(offset, data_range)
449
450    def main(self) -> None:
451        """Run the main loop."""
452        self.loop: urwid.MainLoop = urwid.MainLoop(self.view, self.view.palette)
453        self.loop.run()
454
455    def animate_graph(self, loop: urwid.MainLoop | None = None, user_data: typing.Any = None) -> None:
456        """Update the graph and schedule the next update.
457
458        :param loop: main loop that triggered this alarm, unused
459        :param user_data: alarm user data, unused
460        """
461        self.view.update_graph()
462        self.animate_alarm = self.loop.set_alarm_in(UPDATE_INTERVAL, self.animate_graph)
463
464    def stop_animation(self) -> None:
465        """Stop animating the graph."""
466        if self.animate_alarm:
467            self.loop.remove_alarm(self.animate_alarm)
468        self.animate_alarm = None
469
470
471def main() -> None:
472    """Run the graph example program."""
473    GraphController().main()
474
475
476if __name__ == "__main__":
477    main()