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()