calc.py

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