1#!/usr/bin/env python3
2#
3# Urwid example: a calculator with a normal and a programmer mode
4# Copyright (C) 2026 Urwid developers
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# /// script
23# requires-python = ">=3.11"
24# dependencies = [
25# "urwid>=5.1",
26# ]
27# ///
28"""Example: a calculator with a Normal and a Programmer tab, showing its numbers in big digits.
29
30The module has two parts, so that the user interface can be read on its own:
31
32- The calculator logic: :class:`Calculator` and its two subclasses, plain Python with no urwid in them.
33- The user interface: urwid widgets that show a calculator and send it the keys and the button presses.
34
35The program reads only the keyboard and the mouse, and writes only to the terminal.
36It exits on :kbd:`F8` or the :guilabel:`Exit` button, with status 0.
37"""
38
39from __future__ import annotations
40
41import abc
42import dataclasses
43import decimal
44import operator
45import string
46import sys
47import typing
48
49import urwid
50from urwid.command_map import Command
51
52if typing.TYPE_CHECKING:
53 from collections.abc import Callable, Hashable, Sequence
54
55 from urwid.widget.popup import PopUpParametersModel
56
57# Calculator logic: plain Python, no urwid.
58
59#: the most significant digits a decimal number has, typed or calculated
60MAX_DIGITS: typing.Final = 16
61#: the programmer calculator's numbers are limited to this many bits, plus a sign
62WORD_BITS: typing.Final = 32
63#: the format specification that shows an integer in each base the programmer calculator offers
64BASE_FORMATS: typing.Final[dict[int, str]] = {2: "b", 8: "o", 10: "d", 16: "X"}
65
66_N = typing.TypeVar("_N", decimal.Decimal, int)
67
68
69class Calculator(abc.ABC, typing.Generic[_N]):
70 """Pocket calculator state: the number being typed, the result so far, and the operator waiting for its operand.
71
72 Operators are applied in the order they are entered, as on a pocket calculator: ``2 + 3 * 4 =`` gives 20.
73 A failed operation, such as a division by zero, shows an error.
74 The error stays until the next digit, decimal point, backspace or :meth:`clear`.
75 Subclasses decide what a number is: which digits it has, how it is read and shown, and how operators combine two.
76 """
77
78 def __init__(self) -> None:
79 """Start cleared, showing 0."""
80 self._entry = "" # the number being typed, with a leading "-" when negated
81 self._value: _N | None = None # the result so far; never None while _operator is set
82 self._operator: str | None = None # applied to _value and the next operand
83 self._error: str | None = None # why the last operation failed
84
85 @property
86 @abc.abstractmethod
87 def digits(self) -> str:
88 """The digits a number is typed with, uppercase."""
89
90 @abc.abstractmethod
91 def accepts(self, entry: str) -> bool:
92 """Return whether *entry* is a number short enough to be typed."""
93
94 @abc.abstractmethod
95 def parse(self, text: str) -> _N:
96 """Return the number *text* shows."""
97
98 @abc.abstractmethod
99 def to_text(self, value: _N) -> str:
100 """Return *value* as the display shows it."""
101
102 @abc.abstractmethod
103 def convert(self, number: decimal.Decimal | int) -> _N:
104 """Return *number*, which another calculator showed, as a number of this calculator.
105
106 :raises ArithmeticError: *number* does not fit; the message says why.
107 """
108
109 @abc.abstractmethod
110 def calculate(self, left: _N, operator_key: str, right: _N) -> _N:
111 """Return *left* combined with *right* by the operator named by *operator_key*.
112
113 :raises ArithmeticError: the result is not defined or does not fit; the message says why.
114 """
115
116 @property
117 def error(self) -> str | None:
118 """Why the last operation failed, or ``None`` after a successful one."""
119 return self._error
120
121 @property
122 def number(self) -> _N | None:
123 """The number shown, or ``None`` while an error is shown."""
124 if self._error is not None:
125 return None
126 if self._entry:
127 return self.parse(self._entry)
128 return self._value if self._value is not None else self.parse("0")
129
130 @property
131 def display(self) -> str:
132 """The number to show: the one being typed, else the result so far, else 0."""
133 if self._entry:
134 return self._entry
135 if self._value is not None:
136 return self.to_text(self._value)
137 return "0"
138
139 @property
140 def expression(self) -> str:
141 """The result so far and the operator waiting for its operand, such as ``12 +``, or the error."""
142 if self._error is not None:
143 return self._error
144 if self._operator is None or self._value is None:
145 return ""
146 return f"{self.to_text(self._value)} {self._operator}"
147
148 def clear(self) -> None:
149 """Forget everything, including an error."""
150 self._entry = ""
151 self._value = None
152 self._operator = None
153 self._error = None
154
155 def load(self, number: decimal.Decimal | int | None) -> None:
156 """Forget everything and show *number* as the result, converted by :meth:`convert`.
157
158 ``None``, which :attr:`number` gives while an error is shown, leaves the calculator cleared.
159 """
160 self.clear()
161 if number is None:
162 return
163 try:
164 self._value = self.convert(number)
165 except ArithmeticError as exc:
166 self._error = str(exc)
167
168 def enter_digit(self, digit: str) -> None:
169 """Append *digit* to the number being typed.
170
171 A digit that makes the number too long is ignored.
172
173 :raises ValueError: *digit* is not one of :attr:`digits`.
174 """
175 if digit not in self.digits:
176 raise ValueError(f"{digit!r} is not a digit of this calculator: {self.digits}")
177 if self._error is not None:
178 self.clear()
179 sign = "-" if self._entry.startswith("-") else ""
180 typed = self._entry.removeprefix("-")
181 self._type(sign + ("" if typed == "0" else typed) + digit)
182
183 def _type(self, entry: str) -> None:
184 """Make *entry* the number being typed, unless it is too long."""
185 if self.accepts(entry):
186 self._entry = entry
187
188 def backspace(self) -> None:
189 """Remove the last typed character, or the error."""
190 if self._error is not None:
191 self.clear()
192 self._entry = self._entry[:-1].removesuffix("-")
193
194 def negate(self) -> None:
195 """Change the sign of the number being typed, or of the result when nothing is typed and no operator waits."""
196 if self._error is not None:
197 return
198 if self._entry:
199 self._entry = self._entry.removeprefix("-") if self._entry.startswith("-") else f"-{self._entry}"
200 elif self._value is not None and self._operator is None:
201 self._value = -self._value
202
203 def enter_operator(self, operator_key: str) -> None:
204 """Apply the waiting operator to the typed number, then wait for an operand of *operator_key*.
205
206 Operators entered one after another replace each other, so the last one is used.
207 """
208 if self._error is not None:
209 return
210 if self._entry:
211 self._apply(self.parse(self._entry))
212 elif self._value is None:
213 self._value = self.parse("0")
214 if self._error is None:
215 self._operator = operator_key
216
217 def equals(self) -> None:
218 """Apply the waiting operator.
219
220 With nothing typed, the result so far is the operand, so ``3 * =`` gives 9.
221 """
222 if self._error is not None:
223 return
224 if self._entry:
225 self._apply(self.parse(self._entry))
226 elif self._operator is not None and self._value is not None:
227 self._apply(self._value)
228 self._operator = None
229
230 def _apply(self, operand: _N) -> None:
231 """Combine the result so far with *operand* by the waiting operator, or start with *operand*."""
232 self._entry = ""
233 if self._operator is None or self._value is None:
234 self._value = operand
235 return
236 try:
237 self._value = self.calculate(self._value, self._operator, operand)
238 except ArithmeticError as exc:
239 self._value = None
240 self._operator = None
241 self._error = str(exc)
242
243
244class DecimalCalculator(Calculator[decimal.Decimal]):
245 """Calculator on decimal fractions, rounded to :data:`MAX_DIGITS` significant digits.
246
247 Decimal arithmetic keeps decimal fractions exact: ``0.1 + 0.2 =`` gives 0.3, where binary floats give
248 0.30000000000000004.
249
250 >>> calculator = DecimalCalculator()
251 >>> calculator.enter_point()
252 >>> calculator.enter_digit("1")
253 >>> calculator.enter_operator("+")
254 >>> calculator.enter_point()
255 >>> calculator.enter_digit("2")
256 >>> calculator.equals()
257 >>> calculator.display
258 '0.3'
259 """
260
261 _context: typing.ClassVar[decimal.Context] = decimal.Context(prec=MAX_DIGITS)
262 # E notation keeps 13 significant digits, so a sign, the point and an exponent of up to 7 digits fit one display row
263 _scientific_context: typing.ClassVar[decimal.Context] = decimal.Context(prec=MAX_DIGITS - 3)
264 _operations: typing.ClassVar[
265 dict[str, Callable[[decimal.Context, decimal.Decimal, decimal.Decimal], decimal.Decimal]]
266 ] = {
267 "+": decimal.Context.add,
268 "-": decimal.Context.subtract,
269 "*": decimal.Context.multiply,
270 "/": decimal.Context.divide,
271 }
272
273 @property
274 def digits(self) -> str:
275 """The decimal digits."""
276 return string.digits
277
278 def accepts(self, entry: str) -> bool:
279 """Return whether *entry* has at most :data:`MAX_DIGITS` digits."""
280 return sum(character.isdigit() for character in entry) <= MAX_DIGITS
281
282 def enter_point(self) -> None:
283 """Start the fraction of the number being typed, once."""
284 if self._error is not None:
285 self.clear()
286 if "." not in self._entry:
287 self._type(f"{self._entry or '0'}.")
288
289 def parse(self, text: str) -> decimal.Decimal:
290 """Return the number *text* shows, which may end in a point, such as ``5.``."""
291 return decimal.Decimal(text)
292
293 def to_text(self, value: decimal.Decimal) -> str:
294 """Return *value* as text, in E notation when it has more than :data:`MAX_DIGITS` digits."""
295 value = value.normalize(self._context) if value else decimal.Decimal(0) # no "-0" or "0E+2"
296 text = f"{value:f}"
297 if len(text.removeprefix("-").removeprefix("0.").replace(".", "")) <= MAX_DIGITS:
298 return text
299 return f"{value.normalize(self._scientific_context):E}"
300
301 def convert(self, number: decimal.Decimal | int) -> decimal.Decimal:
302 """Return *number* as a decimal number."""
303 return decimal.Decimal(number)
304
305 def calculate(self, left: decimal.Decimal, operator_key: str, right: decimal.Decimal) -> decimal.Decimal:
306 """Return *left* combined with *right*, rounded to :data:`MAX_DIGITS` significant digits.
307
308 :raises ZeroDivisionError: *operator_key* is ``/`` and *right* is zero.
309 :raises OverflowError: the result is larger than the decimal context allows.
310 """
311 if operator_key == "/" and not right:
312 raise ZeroDivisionError("Division by zero")
313 try:
314 return self._operations[operator_key](self._context, left, right)
315 except decimal.Overflow as exc:
316 raise OverflowError("The result is too large") from exc
317
318
319class IntegerCalculator(Calculator[int]):
320 """Calculator on integers of :data:`WORD_BITS` bits and a sign, shown in base 2, 8, 10 or 16.
321
322 Division drops the remainder, rounding towards zero.
323
324 >>> calculator = IntegerCalculator()
325 >>> calculator.base = 16
326 >>> for digit in "FF":
327 ... calculator.enter_digit(digit)
328 >>> calculator.base = 2
329 >>> calculator.display
330 '11111111'
331 """
332
333 _operations: typing.ClassVar[dict[str, Callable[[int, int], int]]] = {
334 "+": operator.add,
335 "-": operator.sub,
336 "*": operator.mul,
337 }
338
339 def __init__(self) -> None:
340 """Start cleared, in base 10."""
341 super().__init__()
342 self._base = 10
343
344 @property
345 def base(self) -> int:
346 """The base numbers are typed and shown in, one of :data:`BASE_FORMATS`.
347
348 Changing it converts the number being typed.
349 """
350 return self._base
351
352 @base.setter
353 def base(self, base: int) -> None:
354 if base not in BASE_FORMATS:
355 raise ValueError(f"base {base} is not one of {tuple(BASE_FORMATS)}")
356 value = self.parse(self._entry) if self._entry else None
357 self._base = base
358 if value is not None:
359 self._entry = self.to_text(value)
360
361 @property
362 def digits(self) -> str:
363 """The digits of :attr:`base`: 0-1, 0-7, 0-9 or 0-F."""
364 return (string.digits + string.ascii_uppercase)[: self._base]
365
366 def accepts(self, entry: str) -> bool:
367 """Return whether *entry* fits in :data:`WORD_BITS` bits."""
368 return self.parse(entry).bit_length() <= WORD_BITS
369
370 def parse(self, text: str) -> int:
371 """Return the number *text* shows in :attr:`base`."""
372 return int(text, self._base)
373
374 def to_text(self, value: int) -> str:
375 """Return *value* in :attr:`base`, with a leading ``-`` when negative."""
376 return format(value, BASE_FORMATS[self._base])
377
378 def convert(self, number: decimal.Decimal | int) -> int:
379 """Return *number* without its fraction, rounding towards zero.
380
381 :raises OverflowError: *number* does not fit in :data:`WORD_BITS` bits.
382 """
383 # compared before int(), which would build every digit of a decimal with a large exponent
384 if abs(number) >= 2**WORD_BITS:
385 raise OverflowError(f"The number does not fit in {WORD_BITS} bits")
386 return int(number)
387
388 def calculate(self, left: int, operator_key: str, right: int) -> int:
389 """Return *left* combined with *right*.
390
391 :raises ZeroDivisionError: *operator_key* is ``/`` and *right* is zero.
392 :raises OverflowError: the result does not fit in :data:`WORD_BITS` bits.
393 """
394 if operator_key == "/":
395 if not right:
396 raise ZeroDivisionError("Division by zero")
397 quotient = abs(left) // abs(right)
398 result = -quotient if (left < 0) != (right < 0) else quotient
399 else:
400 result = self._operations[operator_key](left, right)
401 if result.bit_length() > WORD_BITS:
402 raise OverflowError(f"The result does not fit in {WORD_BITS} bits")
403 return result
404
405
406# User interface: urwid widgets around the calculators.
407
408FONT: typing.Final = urwid.Thin3x3Font()
409#: big digits in one row of the display: 24 digits of 3 columns fit an 80-column terminal
410DIGITS_PER_ROW: typing.Final = 24
411#: rows of big digits in the display: two hold the 32 digits of a number in base 2
412DISPLAY_ROWS: typing.Final = 2
413#: columns of a button pad: 4 buttons of 9 columns and the gaps between them
414PAD_WIDTH: typing.Final = 39
415#: keys of the operators, the same on the main keyboard and on the numeric keypad
416OPERATOR_KEYS: typing.Final = frozenset("+-*/")
417
418
419@dataclasses.dataclass(frozen=True, slots=True, weakref_slot=True)
420class Theme:
421 """The colors of a theme, named after their role in the Monokai color scheme."""
422
423 background: str
424 panel: str # buttons, bars and dialogs
425 text: str
426 comment: str
427 pink: str
428 orange: str
429 yellow: str
430 green: str
431 blue: str
432 purple: str
433
434
435#: the themes, from the Monokai color scheme and a light variant of it
436THEMES: typing.Final = {
437 "Monokai dark": Theme(
438 background="#272822",
439 panel="#3e3d32",
440 text="#f8f8f2",
441 comment="#75715e",
442 pink="#f92672",
443 orange="#fd971f",
444 yellow="#e6db74",
445 green="#a6e22e",
446 blue="#66d9ef",
447 purple="#ae81ff",
448 ),
449 "Monokai bright": Theme(
450 background="#fafafa",
451 panel="#e6e6dc",
452 text="#272822",
453 comment="#8a8670",
454 pink="#e0115f",
455 orange="#d75f00",
456 yellow="#998f00",
457 green="#5f8700",
458 blue="#0087af",
459 purple="#7a4ec4",
460 ),
461}
462
463#: palette entries: name, foreground and background field of :class:`Theme`, then the 16-color foreground, background
464#: and the monochrome setting, which are the same for every theme
465ENTRIES: typing.Final = (
466 ("body", "text", "background", "light gray", "black", None),
467 ("header", "text", "panel", "white", "dark blue", None),
468 ("footer", "comment", "panel", "light gray", "dark blue", None),
469 ("key", "orange", "panel", "yellow", "dark blue", "bold"),
470 ("expression", "comment", "background", "dark gray", "black", None),
471 ("display", "purple", "background", "light magenta", "black", None),
472 ("error", "pink", "background", "light red", "black", None),
473 ("button", "text", "panel", "black", "light gray", None),
474 ("button focus", "background", "orange", "white", "brown", "standout"),
475 ("operator", "pink", "panel", "dark red", "light gray", None),
476 ("operator focus", "background", "pink", "white", "dark red", "standout"),
477 ("control", "blue", "panel", "dark blue", "light gray", None),
478 ("control focus", "background", "blue", "white", "dark blue", "standout"),
479 ("tab", "comment", "panel", "black", "light gray", None),
480 ("tab selected", "green", "background", "light green", "black", "bold"),
481 ("tab cursor", "background", "green", "black", "light green", "standout"),
482 ("disabled", "comment", "panel", "dark gray", "light gray", None),
483 ("dialog", "text", "panel", "black", "light gray", None),
484)
485
486HELP: typing.Final = """\
487Type on the keyboard or on the numeric keypad, or press the buttons with the mouse or with Space.
488
489 0-9, A-F digits; A-F in base 16
490 + - * / operators, applied from left to right
491 Enter, = result
492 . or , decimal point, on the Normal tab
493 Backspace remove the last digit
494 Esc, Delete clear
495 F9 change the sign
496 Alt+Left/Right switch between the tabs
497 F1 help, F2 theme, F8 exit
498
499The Programmer tab calculates with 32-bit integers and a sign."""
500
501
502def palette(theme: str) -> list[tuple[Hashable, str, str, str | None, str, str]]:
503 """Return the palette of *theme*.
504
505 Terminals with 256 colors or more get the colors of the theme, others get the same basic colors for every theme.
506 """
507 colors = THEMES[theme]
508 return [
509 (name, foreground, background, mono, getattr(colors, foreground_role), getattr(colors, background_role))
510 for name, foreground_role, background_role, foreground, background, mono in ENTRIES
511 ]
512
513
514class Display(urwid.WidgetWrap[urwid.LineBox[urwid.AttrMap[urwid.Pile]]]):
515 """The expression line over the calculator's number in big digits, the last digits in the last row."""
516
517 def __init__(self) -> None:
518 """Build an empty display with :data:`DISPLAY_ROWS` rows of big digits."""
519 self._expression = urwid.Text("", align=urwid.RIGHT)
520 self._rows = [urwid.BigText("", FONT) for _ in range(DISPLAY_ROWS)]
521 pile = urwid.Pile(
522 [
523 urwid.AttrMap(self._expression, "expression"),
524 *(urwid.Padding(big_text, urwid.RIGHT, urwid.WHSettings.CLIP) for big_text in self._rows),
525 ]
526 )
527 super().__init__(urwid.LineBox(urwid.AttrMap(pile, "display")))
528
529 def show(self, calculator: Calculator[typing.Any]) -> None:
530 """Show the state of *calculator*."""
531 self._expression.set_text(calculator.expression)
532 if calculator.error is not None:
533 lines: list[str | tuple[Hashable, str]] = [("error", "E")]
534 else:
535 text = calculator.display
536 lines = [text[max(end - DIGITS_PER_ROW, 0) : end] for end in range(len(text), 0, -DIGITS_PER_ROW)]
537 lines.reverse()
538 lines = [*([""] * (len(self._rows) - len(lines))), *lines]
539 for big_text, line in zip(self._rows, lines, strict=True):
540 big_text.set_text(line)
541
542
543class CalculatorPane(urwid.WidgetWrap[urwid.Filler[urwid.Pile]]):
544 """The buttons of one calculator, which show its state on a display shared with the other panes.
545
546 A key is offered to the calculator before the focused widget, so :kbd:`Enter` gives the result
547 wherever the focus is in the pane, and :kbd:`Space` presses the focused button.
548 A button press goes through :meth:`press` as the key it stands for.
549 """
550
551 def __init__(self, calculator: Calculator[typing.Any], display: Display) -> None:
552 """Send the presses to *calculator* and show its state on *display*.
553
554 Subclasses add the buttons with :meth:`add_pad`.
555 """
556 self.calculator = calculator
557 self.display = display
558 self.pile = urwid.Pile([])
559 super().__init__(urwid.Filler(self.pile, valign=urwid.TOP))
560
561 def button(self, label: str, key: str, attr: str = "button") -> urwid.AttrMap[urwid.Widget]:
562 """Return a button labelled *label* that presses *key*, drawn in *attr* and in ``"<attr> focus"``."""
563 button = urwid.Button(label, on_press=self._on_button, user_data=key, align=urwid.CENTER)
564 cell: urwid.AttrMap[urwid.Widget] = urwid.AttrMap(button, attr, f"{attr} focus")
565 return cell
566
567 def add_pad(
568 self,
569 rows: Sequence[Sequence[urwid.Widget | tuple[typing.Literal[urwid.WHSettings.WEIGHT], int, urwid.Widget]]],
570 ) -> None:
571 """Add *rows* of buttons as a grid of :data:`PAD_WIDTH` columns, and focus it.
572
573 A button with a weight of 2 takes the place of two.
574 """
575 pad = urwid.Pile([urwid.Columns(row, dividechars=1) for row in rows])
576 self.pile.contents.append((urwid.Padding(pad, urwid.CENTER, PAD_WIDTH), self.pile.options()))
577 self.pile.focus_position = len(self.pile.contents) - 1
578
579 def press(self, key: str) -> bool:
580 """Send *key* to the calculator and show the result.
581
582 :returns: whether the calculator takes *key*.
583 """
584 calculator = self.calculator
585 if len(key) == 1 and key.upper() in calculator.digits:
586 calculator.enter_digit(key.upper())
587 elif key in OPERATOR_KEYS:
588 calculator.enter_operator(key)
589 elif key in {"enter", "="}:
590 calculator.equals()
591 elif key == "backspace":
592 calculator.backspace()
593 elif key in {"esc", "delete"}:
594 calculator.clear()
595 elif key == "f9":
596 calculator.negate()
597 elif key in {".", ","} and isinstance(calculator, DecimalCalculator):
598 calculator.enter_point()
599 else:
600 return False
601 self.display.show(calculator)
602 return True
603
604 def keypress(self, size: tuple[()] | tuple[int] | tuple[int, int], key: str) -> str | None:
605 """Give *key* to the calculator, and the keys it does not take to the focused widget."""
606 if self.press(key):
607 return None
608 return super().keypress(size, key)
609
610 def _on_button(self, _button: urwid.Button, key: str) -> None:
611 """Press the key of a clicked button."""
612 self.press(key)
613
614
615class NormalPane(CalculatorPane):
616 """A decimal calculator with the buttons laid out as on a numeric keypad."""
617
618 def __init__(self, display: Display) -> None:
619 """Build the calculator and its button pad, showing it on *display*."""
620 super().__init__(DecimalCalculator(), display)
621 self.add_pad(
622 [
623 [
624 self.button("Clear", "esc", "control"),
625 self.button("←", "backspace", "control"),
626 self.button("±", "f9", "control"),
627 self.button("/", "/", "operator"),
628 ],
629 *(
630 [*(self.button(digit, digit) for digit in digits), self.button(key, key, "operator")]
631 for digits, key in (("789", "*"), ("456", "-"), ("123", "+"))
632 ),
633 [(urwid.WEIGHT, 2, self.button("0", "0")), self.button(".", "."), self.button("=", "enter", "control")],
634 ]
635 )
636
637
638class ProgrammerPane(CalculatorPane):
639 """An integer calculator with a radio button per base over a hexadecimal keypad.
640
641 The keypad keeps every digit in its place: the digits the selected base does not have are greyed out,
642 and the focus skips them.
643 """
644
645 def __init__(self, display: Display) -> None:
646 """Build the calculator, the base selection and the keypad, showing it on *display*."""
647 self._integer = IntegerCalculator()
648 super().__init__(self._integer, display)
649 group: list[urwid.RadioButton] = []
650 bases = urwid.GridFlow(
651 [
652 urwid.Text("Base:"),
653 *(
654 urwid.AttrMap(
655 urwid.RadioButton(
656 group,
657 str(base),
658 state=base == self._integer.base,
659 on_state_change=self._on_base,
660 user_data=base,
661 ),
662 None,
663 "button focus",
664 )
665 for base in BASE_FORMATS
666 ),
667 ],
668 cell_width=7,
669 h_sep=1,
670 v_sep=0,
671 align=urwid.CENTER,
672 )
673 self.pile.contents.append((bases, self.pile.options()))
674 self._digit_cells: dict[str, tuple[urwid.AttrMap[urwid.Widget], urwid.Widget]] = {}
675 for digit in "0123456789ABCDEF":
676 cell = self.button(digit, digit)
677 self._digit_cells[digit] = (cell, cell.original_widget)
678 self._show_digit_buttons()
679 cells = {digit: cell for digit, (cell, _button) in self._digit_cells.items()}
680 self.add_pad(
681 [
682 [cells["D"], cells["E"], cells["F"], self.button("Clear", "esc", "control")],
683 [cells["A"], cells["B"], cells["C"], self.button("←", "backspace", "control")],
684 *(
685 [*(cells[digit] for digit in digits), self.button(key, key, "operator")]
686 for digits, key in (("789", "/"), ("456", "*"), ("123", "-"))
687 ),
688 [
689 self.button("±", "f9", "control"),
690 cells["0"],
691 self.button("=", "enter", "control"),
692 self.button("+", "+", "operator"),
693 ],
694 ]
695 )
696
697 def _show_digit_buttons(self) -> None:
698 """Enable the buttons of the digits of the selected base, and grey out the others."""
699 for digit, (cell, button) in self._digit_cells.items():
700 enabled = digit in self._integer.digits
701 cell.original_widget = button if enabled else urwid.WidgetDisable(button)
702 cell.set_attr_map({None: "button" if enabled else "disabled"})
703
704 def _on_base(self, _radio: urwid.RadioButton, state: bool, base: int) -> None:
705 """Switch the calculator to the base of a radio button that was selected."""
706 if state:
707 self._integer.base = base
708 self._show_digit_buttons()
709 self.display.show(self._integer)
710
711
712class ThemeButton(urwid.PopUpLauncher[urwid.Button]):
713 """A button that opens a pop-up with a radio button per theme.
714
715 Selecting a radio button shows its theme at once.
716 :guilabel:`OK` keeps it; :guilabel:`Cancel`, :kbd:`Esc` and a click outside the pop-up show the saved theme again.
717 """
718
719 def __init__(self, screen: urwid.BaseScreen) -> None:
720 """Wrap a :guilabel:`Theme` button that changes the palette of *screen*.
721
722 The first theme is the saved one.
723 """
724 super().__init__(urwid.Button("Theme", on_press=self._on_open), close_on_menu=True, close_on_click_outside=True)
725 self._screen = screen
726 self.theme = next(iter(THEMES)) # the saved theme
727 self._shown = self.theme
728
729 def create_pop_up(self) -> urwid.AttrMap[urwid.LineBox[urwid.Filler[urwid.Pile]]]:
730 """Return the theme radio buttons over :guilabel:`OK` and :guilabel:`Cancel`."""
731 group: list[urwid.RadioButton] = []
732 radios = [
733 urwid.AttrMap(
734 urwid.RadioButton(
735 group, name, state=name == self.theme, on_state_change=self._on_select, user_data=name
736 ),
737 None,
738 "button focus",
739 )
740 for name in THEMES
741 ]
742 buttons = urwid.Columns(
743 [
744 urwid.AttrMap(urwid.Button(label, on_press=callback, align=urwid.CENTER), None, "button focus")
745 for label, callback in (("OK", self._on_ok), ("Cancel", self._on_cancel))
746 ],
747 dividechars=2,
748 )
749 body = urwid.Pile([*radios, urwid.Divider(), buttons])
750 return urwid.AttrMap(urwid.LineBox(urwid.Filler(body, valign=urwid.TOP), title="Theme"), "dialog")
751
752 def get_pop_up_parameters(self) -> PopUpParametersModel:
753 """Place the pop-up under the button, or over it when there is no room below."""
754 return {"left": 0, "top": 1, "overlay_width": 26, "overlay_height": len(THEMES) + 4, "anchor_height": 1}
755
756 def close_pop_up(self) -> None:
757 """Close the pop-up and show the saved theme.
758
759 :guilabel:`OK` saves the shown theme before it closes the pop-up.
760 """
761 self._show(self.theme)
762 super().close_pop_up()
763
764 def _show(self, theme: str) -> None:
765 """Draw the screen in *theme*."""
766 if theme != self._shown:
767 self._screen.register_palette(palette(theme), replace=True)
768 self._shown = theme
769
770 def _on_open(self, _button: urwid.Button) -> None:
771 """Open the pop-up when the button is pressed."""
772 self.open_pop_up()
773
774 def _on_select(self, _radio: urwid.RadioButton, state: bool, theme: str) -> None:
775 """Show the theme of a radio button that was selected."""
776 if state:
777 self._show(theme)
778
779 def _on_ok(self, _button: urwid.Button) -> None:
780 """Save the shown theme and close the pop-up."""
781 self.theme = self._shown
782 self.close_pop_up()
783
784 def _on_cancel(self, _button: urwid.Button) -> None:
785 """Close the pop-up, which shows the saved theme again."""
786 self.close_pop_up()
787
788
789def main() -> int:
790 """Build the window, run the main loop until F8 or the Exit button, and return the exit status."""
791 screen = urwid.display.raw.Screen()
792 theme_button = ThemeButton(screen)
793 display = Display()
794 panes = (NormalPane(display), ProgrammerPane(display))
795 tabs = urwid.Tabs(
796 [("Normal", panes[0]), ("Programmer", panes[1])],
797 bar_selectable=True,
798 attr_map="tab",
799 selected_map="tab selected",
800 focus_map="tab cursor",
801 )
802 display.show(panes[0].calculator)
803
804 def on_tab_changed(_tabs: urwid.Tabs, old: int | None, new: int | None) -> None:
805 """Move the number shown to the calculator of the selected tab, converted to its kind of number."""
806 if old is None or new is None:
807 raise ValueError("the calculator tabs are never removed")
808 calculator = panes[new].calculator
809 calculator.load(panes[old].calculator.number)
810 display.show(calculator)
811
812 urwid.connect_signal(tabs, "focus changed", on_tab_changed)
813
814 def show_help(_button: urwid.Button | None = None) -> None:
815 if stack.depth == 0 and theme_button.pop_up_widget is None:
816 stack.open(help_dialog)
817
818 def exit_program(_button: urwid.Button | None = None) -> typing.NoReturn:
819 raise urwid.ExitMainLoop
820
821 header = urwid.Columns(
822 [
823 *(
824 (urwid.PACK, urwid.AttrMap(widget, None, "button focus"))
825 for widget in (
826 urwid.Button("Help", on_press=show_help),
827 theme_button,
828 urwid.Button("Exit", on_press=exit_program),
829 )
830 ),
831 urwid.Text("Calculator ", align=urwid.RIGHT),
832 ],
833 dividechars=1,
834 )
835 body = urwid.Pile([(urwid.PACK, urwid.AttrMap(header, "header")), (urwid.PACK, display), tabs])
836 body.focus_position = 2
837 keys = (("F1", "help"), ("F2", "theme"), ("F8", "exit"), ("Esc", "clear"), ("Alt+←/→", "tabs"))
838 footer = urwid.Text([part for key, action in keys for part in (("key", f" {key}"), f" {action} ")])
839 frame: urwid.Frame[urwid.AttrMap[urwid.Pile], None, urwid.AttrMap[urwid.Text]] = urwid.Frame(
840 urwid.AttrMap(body, "body"), footer=urwid.AttrMap(footer, "footer")
841 )
842 stack = urwid.OverlayStack(frame, close_on_menu=True, close_on_click_outside=True)
843
844 def close_help(_button: urwid.Button) -> None:
845 stack.close()
846
847 ok_button = urwid.Button("OK", on_press=close_help, align=urwid.CENTER)
848 help_dialog = urwid.AttrMap(
849 urwid.LineBox(
850 urwid.Pile(
851 [
852 urwid.Text(HELP),
853 urwid.Divider(),
854 urwid.Padding(urwid.AttrMap(ok_button, None, "button focus"), urwid.CENTER, 8),
855 ]
856 ),
857 title="Help",
858 ),
859 "dialog",
860 )
861
862 def unhandled_input(key: str | tuple[str, int, int, int]) -> None:
863 if key == "f8":
864 exit_program()
865 elif key == "f1":
866 show_help()
867 elif stack.depth == 0 and theme_button.pop_up_widget is None:
868 if key == "f2":
869 theme_button.open_pop_up()
870 # the keys the tab bar and the header leave unhandled go to the calculator of the selected tab
871 elif isinstance(key, str) and isinstance(tabs.focus, CalculatorPane):
872 tabs.focus.press(key)
873
874 urwid.command_map.bind(Command.NEXT_TAB, "meta right")
875 urwid.command_map.bind(Command.PREVIOUS_TAB, "meta left")
876 loop = urwid.MainLoop(
877 stack,
878 palette(theme_button.theme),
879 screen=screen,
880 unhandled_input=unhandled_input,
881 pop_ups=True,
882 )
883 loop.run()
884 return 0
885
886
887if __name__ == "__main__":
888 sys.exit(main())