Created
February 20, 2026 13:05
-
-
Save sunmeat/bd623d7baefdb05c4a9af295da40f032 to your computer and use it in GitHub Desktop.
MVT (textual)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from textual.app import App, ComposeResult, on | |
| from textual.widgets import Header, Footer, Button, Input, Static, Label, Rule | |
| from textual.containers import Vertical, Horizontal, ScrollableContainer | |
| from textual.screen import Screen | |
| # ──────────────────────────────────────────────── | |
| # Model | |
| # ──────────────────────────────────────────────── | |
| class ShoppingModel: | |
| def __init__(self): | |
| self.items: list[dict] = [] | |
| def add_item(self, name: str, quantity: int, price: float) -> None: | |
| self.items.append({"name": name, "quantity": quantity, "price": price}) | |
| def get_all_items(self) -> list[dict]: | |
| return self.items.copy() | |
| def calculate_total(self) -> float: | |
| return sum(item["quantity"] * item["price"] for item in self.items) | |
| def clear(self) -> None: | |
| self.items.clear() | |
| # ──────────────────────────────────────────────── | |
| # Template | |
| # ──────────────────────────────────────────────── | |
| class ShoppingTemplate: | |
| @staticmethod | |
| def format_item(index: int, item: dict) -> str: | |
| subtotal = item["quantity"] * item["price"] | |
| return ( | |
| f"[dim]{index:>2}.[/] " | |
| f"[bold white]{item['name']:<20}[/]" | |
| f"[cyan]{item['quantity']:>3}[/] × " | |
| f"[yellow]{item['price']:>7.2f}[/] = " | |
| f"[green bold]{subtotal:>9.2f} ₴[/]" | |
| ) | |
| @staticmethod | |
| def format_list(items: list[dict], total: float) -> str: | |
| if not items: | |
| return ( | |
| "\n\n" | |
| "[dim]╔══════════════════════════════╗[/]\n" | |
| "[dim]║[/] [italic]Список порожній[/] [dim] ║[/]\n" | |
| "[dim]║[/] Додайте перший товар! [dim] ║[/]\n" | |
| "[dim]╚══════════════════════════════╝[/]" | |
| ) | |
| lines = [ | |
| ShoppingTemplate.format_item(i + 1, item) | |
| for i, item in enumerate(items) | |
| ] | |
| divider = "[dim]" + "─" * 52 + "[/]" | |
| total_line = f"\n [bold]РАЗОМ:[/] [green bold reverse] {total:>10.2f} ₴ [/]\n" | |
| return "\n".join(lines) + f"\n\n{divider}{total_line}" | |
| # ──────────────────────────────────────────────── | |
| # Views / Screens | |
| # ──────────────────────────────────────────────── | |
| class MainMenuScreen(Screen): | |
| def compose(self) -> ComposeResult: | |
| yield Header(show_clock=True) | |
| with Vertical(id="menu-box"): | |
| yield Label("СПИСОК ПОКУПОК", id="title") | |
| yield Label("Оберіть дію нижче", id="subtitle") | |
| yield Rule() | |
| yield Button("Додати товар", id="add", variant="primary") | |
| yield Button("Переглянути список", id="view", variant="success") | |
| yield Button("Очистити список", id="clear", variant="warning") | |
| yield Button("Вихід", id="quit", variant="error") | |
| yield Footer() | |
| @on(Button.Pressed, "#add") | |
| def push_add(self): | |
| self.app.push_screen(AddScreen()) | |
| @on(Button.Pressed, "#view") | |
| def push_view(self): | |
| self.app.push_screen(ViewScreen()) | |
| @on(Button.Pressed, "#clear") | |
| def do_clear(self): | |
| count = len(self.app.model.items) | |
| self.app.model.clear() | |
| self.notify( | |
| f"Видалено {count} товар(ів)" if count else "Список вже порожній", | |
| severity="warning" | |
| ) | |
| @on(Button.Pressed, "#quit") | |
| def do_quit(self): | |
| self.app.exit() | |
| class AddScreen(Screen): | |
| def compose(self) -> ComposeResult: | |
| yield Header(show_clock=True) | |
| with Vertical(id="add-box"): | |
| yield Label("+ ДОДАТИ ТОВАР", id="title") | |
| yield Label("Заповніть усі поля", id="subtitle") | |
| yield Rule() | |
| yield Label("Назва товару", classes="field-label") | |
| yield Input(placeholder="напр. Молоко", id="name") | |
| yield Label("Кількість (шт)", classes="field-label") | |
| yield Input(placeholder="напр. 2", id="qty", type="integer") | |
| yield Label("Ціна за одиницю (₴)", classes="field-label") | |
| yield Input(placeholder="напр. 45.50", id="price", type="number") | |
| yield Rule() | |
| with Horizontal(id="btn-row"): | |
| yield Button("Зберегти", id="save", variant="primary") | |
| yield Button("Скасувати", id="back", variant="default") | |
| yield Footer() | |
| def on_mount(self) -> None: | |
| self.query_one("#name", Input).focus() | |
| @on(Button.Pressed, "#save") | |
| def save(self): | |
| try: | |
| name = self.query_one("#name", Input).value.strip() | |
| qty_str = self.query_one("#qty", Input).value.strip() | |
| price_str = self.query_one("#price", Input).value.strip() | |
| if not name: | |
| self.notify("Введіть назву товару", severity="error") | |
| return | |
| if not qty_str: | |
| self.notify("Введіть кількість", severity="error") | |
| return | |
| if not price_str: | |
| self.notify("Введіть ціну", severity="error") | |
| return | |
| qty = int(qty_str) | |
| price = float(price_str) | |
| if qty < 1: | |
| self.notify("Кількість має бути ≥ 1", severity="error") | |
| return | |
| if price <= 0: | |
| self.notify("Ціна має бути > 0", severity="error") | |
| return | |
| self.app.model.add_item(name, qty, price) | |
| self.notify(f"Додано: {name}", severity="information") | |
| self.app.pop_screen() | |
| except ValueError: | |
| self.notify("Невірний формат числа", severity="error") | |
| @on(Button.Pressed, "#back") | |
| def back(self): | |
| self.app.pop_screen() | |
| class ViewScreen(Screen): | |
| def compose(self) -> ComposeResult: | |
| yield Header(show_clock=True) | |
| with Vertical(id="view-box"): | |
| yield Label("МОЇ ПОКУПКИ", id="title") | |
| yield Label("", id="subtitle") | |
| yield Rule() | |
| with ScrollableContainer(id="scroll"): | |
| yield Static(id="list", markup=True) | |
| yield Rule() | |
| yield Button("< Назад", id="back", variant="primary") | |
| yield Footer() | |
| def on_mount(self) -> None: | |
| self.refresh_list() | |
| def refresh_list(self): | |
| items = self.app.model.get_all_items() | |
| total = self.app.model.calculate_total() | |
| text = ShoppingTemplate.format_list(items, total) | |
| self.query_one("#list", Static).update(text) | |
| self.query_one("#subtitle", Label).update( | |
| f"{len(items)} товар(ів) у списку" | |
| ) | |
| @on(Button.Pressed, "#back") | |
| def back(self): | |
| self.app.pop_screen() | |
| # ──────────────────────────────────────────────── | |
| # App | |
| # ──────────────────────────────────────────────── | |
| class ShoppingApp(App[None]): | |
| dark = True | |
| CSS = """ | |
| Screen { | |
| background: $background; | |
| } | |
| Header, Footer { | |
| background: #1a1a2e; | |
| } | |
| Header { | |
| color: #e0e0ff; | |
| text-style: bold; | |
| } | |
| Footer { | |
| color: #888; | |
| } | |
| Rule { | |
| color: #333355; | |
| margin: 1 0; | |
| } | |
| #menu-box, #add-box, #view-box { | |
| width: 60; | |
| border: round #2a2a5a; | |
| background: #0f0f23; | |
| padding: 2 3; | |
| margin: 1 4; | |
| align: center top; | |
| } | |
| #view-box { | |
| width: 72; | |
| } | |
| #title { | |
| text-align: center; | |
| text-style: bold; | |
| color: #7b7bff; | |
| width: 100%; | |
| } | |
| #subtitle { | |
| text-align: center; | |
| color: #777799; | |
| width: 100%; | |
| margin: 0 0 1 0; | |
| } | |
| Button { | |
| min-height: 3; | |
| height: 3; | |
| } | |
| #menu-box Button { | |
| width: 100%; | |
| margin: 0 0 1 0; | |
| } | |
| #btn-row { | |
| width: 100%; | |
| margin: 1 0; | |
| } | |
| #btn-row Button { | |
| width: 1fr; | |
| margin: 0 1; | |
| } | |
| Button.-primary { background: #2d2d8f; color: #e0e0ff; border: tall #5555ff; } | |
| Button.-success { background: #1a4a2a; color: #c0ffc0; border: tall #2a8a3a; } | |
| Button.-warning { background: #4a3a00; color: #ffdd88; border: tall #8a6a00; } | |
| Button.-error { background: #4a1a1a; color: #ffaaaa; border: tall #8a3030; } | |
| Button.-default { background: #222244; color: #bbbbdd; border: tall #444466; } | |
| .field-label { | |
| color: #8888ff; | |
| margin: 1 0 0 0; | |
| text-style: bold; | |
| } | |
| Input { | |
| width: 100%; | |
| height: 3; | |
| background: #0d0d1f; | |
| border: round #333366; | |
| color: #ddddff; | |
| } | |
| Input:focus { | |
| border: round #7777ff; | |
| } | |
| #scroll { | |
| height: 1fr; | |
| border: round #222244; | |
| background: #080818; | |
| padding: 1 2; | |
| } | |
| #list { | |
| width: 100%; | |
| } | |
| """ | |
| BINDINGS = [ | |
| ("q", "quit", "Вихід"), | |
| ("t", "toggle_dark", "Змінити тему"), | |
| ] | |
| def __init__(self): | |
| super().__init__() | |
| self.model = ShoppingModel() | |
| def action_toggle_dark(self): | |
| self.dark = not self.dark | |
| def on_mount(self) -> None: | |
| self.push_screen(MainMenuScreen()) | |
| if __name__ == "__main__": | |
| ShoppingApp().run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment