Skip to content

Instantly share code, notes, and snippets.

@sunmeat
Created February 20, 2026 13:05
Show Gist options
  • Select an option

  • Save sunmeat/bd623d7baefdb05c4a9af295da40f032 to your computer and use it in GitHub Desktop.

Select an option

Save sunmeat/bd623d7baefdb05c4a9af295da40f032 to your computer and use it in GitHub Desktop.
MVT (textual)
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