coffee-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
coffee_cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Coffee CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
6
+
coffee_cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run Coffee CLI with ``python -m coffee_cli``."""
2
+
3
+ from coffee_cli.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
7
+
coffee_cli/app.py ADDED
@@ -0,0 +1,344 @@
1
+ """Textual application for Coffee CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.reactive import reactive
11
+ from textual.timer import Timer
12
+ from textual.widgets import Footer, Header, Static
13
+
14
+ from coffee_cli.brewing import get_brew_stage, is_brew_complete, next_brew_index
15
+ from coffee_cli.home import HOME_MENU_ITEMS, get_menu_item, move_selection, select_menu_item
16
+ from coffee_cli.order import (
17
+ CONFIRM_OPTIONS,
18
+ OrderDraft,
19
+ OrderStep,
20
+ apply_order_choice,
21
+ get_order_options,
22
+ move_order_selection,
23
+ next_order_step,
24
+ previous_order_step,
25
+ )
26
+ from coffee_cli.personal import (
27
+ PersonalData,
28
+ add_favorite,
29
+ add_journal_entry,
30
+ add_order_history,
31
+ draft_to_saved_drink,
32
+ load_personal_data,
33
+ make_journal_entry,
34
+ save_personal_data,
35
+ )
36
+ from coffee_cli.ui_text import (
37
+ STEAM_FRAMES,
38
+ render_brewing_panel,
39
+ render_brewing_summary,
40
+ render_favorites_panel,
41
+ render_hero,
42
+ render_journal_panel,
43
+ render_menu,
44
+ render_order_menu,
45
+ render_order_summary,
46
+ render_personal_summary,
47
+ render_statistics_panel,
48
+ render_summary,
49
+ )
50
+
51
+
52
+ class CoffeeApp(App[None]):
53
+ """The first interactive shell for Coffee CLI."""
54
+
55
+ CSS = """
56
+ Screen {
57
+ background: #101820;
58
+ color: #f5efe6;
59
+ }
60
+
61
+ #layout {
62
+ height: 1fr;
63
+ }
64
+
65
+ #left-pane {
66
+ width: 2fr;
67
+ padding: 1 2;
68
+ }
69
+
70
+ #right-pane {
71
+ width: 1fr;
72
+ padding: 2;
73
+ border: round #2bb3a3;
74
+ background: #17242d;
75
+ }
76
+
77
+ #hero {
78
+ margin-bottom: 1;
79
+ }
80
+
81
+ #menu {
82
+ border: round #ffbf69;
83
+ padding: 1 2;
84
+ background: #161f27;
85
+ }
86
+ """
87
+
88
+ BINDINGS = [
89
+ Binding("up,k", "move_up", "Up"),
90
+ Binding("down,j", "move_down", "Down"),
91
+ Binding("enter", "select_item", "Select"),
92
+ Binding("escape", "go_back", "Back"),
93
+ Binding("q", "quit", "Quit"),
94
+ ]
95
+
96
+ active_view = reactive("home")
97
+ selected_index = reactive(0)
98
+ order_step_index = reactive(0)
99
+ brew_stage_index = reactive(0)
100
+ steam_index = reactive(0)
101
+ status_message = reactive("Navigate the menu. This is Milestone 1.")
102
+
103
+ def __init__(self, personal_data_path: Path | None = None) -> None:
104
+ super().__init__()
105
+ self.order_step: OrderStep = "drink"
106
+ self.order_draft = OrderDraft()
107
+ self.brew_timer: Timer | None = None
108
+ self.personal_data_path = personal_data_path
109
+ self.personal_data: PersonalData = load_personal_data(personal_data_path)
110
+
111
+ def compose(self) -> ComposeResult:
112
+ yield Header(show_clock=True)
113
+ with Horizontal(id="layout"):
114
+ with Vertical(id="left-pane"):
115
+ yield Static(id="hero")
116
+ yield Static(id="menu")
117
+ yield Static(id="right-pane")
118
+ yield Footer()
119
+
120
+ def on_mount(self) -> None:
121
+ self.set_interval(0.35, self.advance_steam)
122
+ self.refresh_view()
123
+
124
+ def watch_selected_index(self) -> None:
125
+ self.refresh_view()
126
+
127
+ def watch_order_step_index(self) -> None:
128
+ self.refresh_view()
129
+
130
+ def watch_brew_stage_index(self) -> None:
131
+ self.refresh_view()
132
+
133
+ def watch_active_view(self) -> None:
134
+ self.refresh_view()
135
+
136
+ def watch_status_message(self) -> None:
137
+ self.refresh_view()
138
+
139
+ def watch_steam_index(self) -> None:
140
+ self.refresh_view()
141
+
142
+ def advance_steam(self) -> None:
143
+ self.steam_index = (self.steam_index + 1) % len(STEAM_FRAMES)
144
+
145
+ def action_move_up(self) -> None:
146
+ if self.active_view == "brewing":
147
+ return
148
+ if self.active_view == "order":
149
+ self.order_step_index = move_order_selection(
150
+ self.order_step_index,
151
+ -1,
152
+ len(get_order_options(self.order_step)),
153
+ )
154
+ return
155
+ self.selected_index = move_selection(self.selected_index, -1)
156
+
157
+ def action_move_down(self) -> None:
158
+ if self.active_view == "brewing":
159
+ return
160
+ if self.active_view == "order":
161
+ self.order_step_index = move_order_selection(
162
+ self.order_step_index,
163
+ 1,
164
+ len(get_order_options(self.order_step)),
165
+ )
166
+ return
167
+ self.selected_index = move_selection(self.selected_index, 1)
168
+
169
+ def action_select_item(self) -> None:
170
+ if self.active_view in {"favorites", "journal", "statistics"}:
171
+ return
172
+
173
+ if self.active_view == "brewing":
174
+ if is_brew_complete(self.brew_stage_index):
175
+ self.return_home_after_brew()
176
+ return
177
+
178
+ if self.active_view == "order":
179
+ self.select_order_item()
180
+ return
181
+
182
+ selection = select_menu_item(self.selected_index)
183
+ if selection.should_exit:
184
+ self.exit()
185
+ return
186
+ if selection.item.label == "Order Coffee":
187
+ self.start_order()
188
+ return
189
+ if selection.item.label == "My Favorites":
190
+ self.open_personal_view("favorites", "Viewing saved favorite drinks.")
191
+ return
192
+ if selection.item.label == "Coffee Journal":
193
+ self.open_personal_view("journal", "Viewing coffee journal entries.")
194
+ return
195
+ if selection.item.label == "Statistics":
196
+ self.open_personal_view("statistics", "Viewing coffee statistics.")
197
+ return
198
+ self.status_message = selection.item.status
199
+
200
+ def action_go_back(self) -> None:
201
+ if self.active_view in {"favorites", "journal", "statistics"}:
202
+ self.active_view = "home"
203
+ self.status_message = "Back at home."
204
+ return
205
+
206
+ if self.active_view == "brewing":
207
+ return
208
+
209
+ if self.active_view != "order":
210
+ return
211
+
212
+ previous_step = previous_order_step(self.order_step)
213
+ if previous_step is None:
214
+ self.active_view = "home"
215
+ self.status_message = "Order canceled. Back at home."
216
+ return
217
+
218
+ self.order_step = previous_step
219
+ self.order_step_index = 0
220
+ self.status_message = "Step back and adjust the order."
221
+
222
+ def start_order(self) -> None:
223
+ self.active_view = "order"
224
+ self.order_step = "drink"
225
+ self.order_step_index = 0
226
+ self.order_draft = OrderDraft()
227
+ self.status_message = "Choose a coffee to start the order."
228
+
229
+ def open_personal_view(self, view_name: str, message: str) -> None:
230
+ self.active_view = view_name
231
+ self.status_message = message
232
+
233
+ def select_order_item(self) -> None:
234
+ if self.order_step == "confirm":
235
+ selected = CONFIRM_OPTIONS[self.order_step_index]
236
+ if selected.label == "Confirm Order":
237
+ self.start_brewing()
238
+ return
239
+ if selected.label == "Start Over":
240
+ self.start_order()
241
+ return
242
+ self.active_view = "home"
243
+ self.status_message = "Order canceled. Back at home."
244
+ return
245
+
246
+ self.order_draft = apply_order_choice(
247
+ self.order_draft,
248
+ self.order_step,
249
+ self.order_step_index,
250
+ )
251
+ self.order_step = next_order_step(self.order_step)
252
+ self.order_step_index = 0
253
+ self.status_message = "Order updated. Keep going."
254
+
255
+ def start_brewing(self) -> None:
256
+ self.record_completed_order()
257
+ self.active_view = "brewing"
258
+ self.brew_stage_index = 0
259
+ self.status_message = "Brewing your order."
260
+ if self.brew_timer is not None:
261
+ self.brew_timer.stop()
262
+ self.brew_timer = self.set_interval(0.75, self.advance_brewing)
263
+
264
+ def advance_brewing(self) -> None:
265
+ if is_brew_complete(self.brew_stage_index):
266
+ if self.brew_timer is not None:
267
+ self.brew_timer.stop()
268
+ self.brew_timer = None
269
+ self.status_message = "Coffee ready. Press Enter to return home."
270
+ return
271
+
272
+ self.brew_stage_index = next_brew_index(self.brew_stage_index)
273
+ if is_brew_complete(self.brew_stage_index):
274
+ self.status_message = "Coffee ready. Press Enter to return home."
275
+
276
+ def return_home_after_brew(self) -> None:
277
+ if self.brew_timer is not None:
278
+ self.brew_timer.stop()
279
+ self.brew_timer = None
280
+ self.active_view = "home"
281
+ self.selected_index = 0
282
+ self.order_step = "drink"
283
+ self.order_step_index = 0
284
+ self.brew_stage_index = 0
285
+ self.order_draft = OrderDraft()
286
+ self.status_message = "Order complete. Ready for another coffee."
287
+
288
+ def record_completed_order(self) -> None:
289
+ drink = draft_to_saved_drink(self.order_draft)
290
+ self.personal_data = add_order_history(self.personal_data, drink)
291
+ self.personal_data = add_favorite(self.personal_data, drink)
292
+ self.personal_data = add_journal_entry(self.personal_data, make_journal_entry(drink))
293
+ save_personal_data(self.personal_data, self.personal_data_path)
294
+
295
+ def refresh_view(self) -> None:
296
+ steam_frame = STEAM_FRAMES[self.steam_index]
297
+ self.query_one("#hero", Static).update(render_hero(steam_frame))
298
+
299
+ if self.active_view == "favorites":
300
+ self.query_one("#menu", Static).update(render_favorites_panel(self.personal_data))
301
+ self.query_one("#right-pane", Static).update(
302
+ render_personal_summary(self.personal_data, self.status_message)
303
+ )
304
+ return
305
+
306
+ if self.active_view == "journal":
307
+ self.query_one("#menu", Static).update(render_journal_panel(self.personal_data))
308
+ self.query_one("#right-pane", Static).update(
309
+ render_personal_summary(self.personal_data, self.status_message)
310
+ )
311
+ return
312
+
313
+ if self.active_view == "statistics":
314
+ self.query_one("#menu", Static).update(render_statistics_panel(self.personal_data))
315
+ self.query_one("#right-pane", Static).update(
316
+ render_personal_summary(self.personal_data, self.status_message)
317
+ )
318
+ return
319
+
320
+ if self.active_view == "brewing":
321
+ stage = get_brew_stage(self.brew_stage_index)
322
+ self.query_one("#menu", Static).update(
323
+ render_brewing_panel(stage, is_brew_complete(self.brew_stage_index))
324
+ )
325
+ self.query_one("#right-pane", Static).update(
326
+ render_brewing_summary(self.order_draft, stage)
327
+ )
328
+ return
329
+
330
+ if self.active_view == "order":
331
+ options = get_order_options(self.order_step)
332
+ self.query_one("#menu", Static).update(
333
+ render_order_menu(self.order_step, options, self.order_step_index)
334
+ )
335
+ self.query_one("#right-pane", Static).update(
336
+ render_order_summary(self.order_draft, self.status_message)
337
+ )
338
+ return
339
+
340
+ selected_item = get_menu_item(self.selected_index)
341
+ self.query_one("#menu", Static).update(render_menu(HOME_MENU_ITEMS, self.selected_index))
342
+ self.query_one("#right-pane", Static).update(
343
+ render_summary(selected_item, self.status_message)
344
+ )
coffee_cli/brewing.py ADDED
@@ -0,0 +1,49 @@
1
+ """Brewing animation state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class BrewStage:
10
+ """One visible step in the brewing animation."""
11
+
12
+ label: str
13
+ detail: str
14
+ progress: int
15
+
16
+
17
+ BREW_STAGES = (
18
+ BrewStage("Grinding beans", "Breaking the beans into a clean, even grind.", 2),
19
+ BrewStage("Heating water", "Bringing water up to brewing temperature.", 4),
20
+ BrewStage("Brewing coffee", "Extracting the drink you built.", 7),
21
+ BrewStage("Pouring cup", "Finishing the order and settling the foam.", 9),
22
+ BrewStage("Ready", "Your coffee is ready. Enjoy.", 10),
23
+ )
24
+
25
+
26
+ def clamp_brew_index(index: int) -> int:
27
+ """Clamp a brewing index to the available stages."""
28
+ return max(0, min(index, len(BREW_STAGES) - 1))
29
+
30
+
31
+ def get_brew_stage(index: int) -> BrewStage:
32
+ """Return a brewing stage by clamped index."""
33
+ return BREW_STAGES[clamp_brew_index(index)]
34
+
35
+
36
+ def next_brew_index(index: int) -> int:
37
+ """Advance the brewing animation by one stage."""
38
+ return clamp_brew_index(index + 1)
39
+
40
+
41
+ def is_brew_complete(index: int) -> bool:
42
+ """Return whether the brewing animation reached the final stage."""
43
+ return clamp_brew_index(index) == len(BREW_STAGES) - 1
44
+
45
+
46
+ def render_progress_bar(progress: int, width: int = 10) -> str:
47
+ """Render a text progress bar."""
48
+ bounded = max(0, min(progress, width))
49
+ return "█" * bounded + "░" * (width - bounded)
coffee_cli/cli.py ADDED
@@ -0,0 +1,41 @@
1
+ """Console entry point for Coffee CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Protocol
7
+
8
+
9
+ class RunnableApp(Protocol):
10
+ """Protocol for apps with a Textual-compatible run method."""
11
+
12
+ def run(self) -> None:
13
+ """Start the UI."""
14
+
15
+
16
+ def load_app_class() -> type[RunnableApp]:
17
+ """Import the Textual app lazily so basic tests can run without UI deps."""
18
+ from coffee_cli.app import CoffeeApp
19
+
20
+ return CoffeeApp
21
+
22
+
23
+ def main(app_factory: Callable[[], RunnableApp] | None = None) -> int:
24
+ """Launch Coffee CLI."""
25
+ factory = app_factory
26
+ if factory is None:
27
+ try:
28
+ factory = load_app_class()
29
+ except ModuleNotFoundError as exc:
30
+ if exc.name == "textual":
31
+ print(
32
+ "Coffee CLI requires the 'textual' package. "
33
+ "Install project dependencies with: python -m pip install -e '.[dev]'"
34
+ )
35
+ return 1
36
+ raise
37
+
38
+ app = factory()
39
+ app.run()
40
+ return 0
41
+
coffee_cli/home.py ADDED
@@ -0,0 +1,89 @@
1
+ """Home screen state and menu behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class MenuItem:
10
+ """A selectable item on the Coffee CLI home screen."""
11
+
12
+ label: str
13
+ detail: str
14
+ status: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class MenuSelection:
19
+ """Result of selecting a home menu item."""
20
+
21
+ item: MenuItem
22
+ should_exit: bool = False
23
+
24
+
25
+ HOME_MENU_ITEMS = (
26
+ MenuItem(
27
+ "Order Coffee",
28
+ "Start a guided coffee order with drink, size, milk, and summary.",
29
+ "Opening the guided order flow.",
30
+ ),
31
+ MenuItem(
32
+ "Today's Special",
33
+ "Preview the daily recommendation and what makes it worth trying.",
34
+ "Daily specials are planned after ordering works.",
35
+ ),
36
+ MenuItem(
37
+ "My Favorites",
38
+ "Keep repeat orders close for fast access.",
39
+ "Favorites arrive once local storage is introduced.",
40
+ ),
41
+ MenuItem(
42
+ "Coffee Journal",
43
+ "Track what you brewed, what you liked, and what to improve.",
44
+ "Journal entries are planned for a later milestone.",
45
+ ),
46
+ MenuItem(
47
+ "Brewing Guide",
48
+ "Learn simple brew methods and ratios from inside the terminal.",
49
+ "Guides will expand after the first order flow.",
50
+ ),
51
+ MenuItem(
52
+ "Statistics",
53
+ "Review drink history, favorite styles, and usage patterns.",
54
+ "Stats need persisted order history first.",
55
+ ),
56
+ MenuItem(
57
+ "Settings",
58
+ "Tune themes, preferences, and future app behavior.",
59
+ "Settings are planned after the playable core is stable.",
60
+ ),
61
+ MenuItem(
62
+ "Exit",
63
+ "Close Coffee CLI and return to the shell.",
64
+ "Closing Coffee CLI.",
65
+ ),
66
+ )
67
+
68
+
69
+ def clamp_menu_index(index: int, item_count: int = len(HOME_MENU_ITEMS)) -> int:
70
+ """Wrap a menu index into the valid range."""
71
+ if item_count < 1:
72
+ raise ValueError("menu must contain at least one item")
73
+ return index % item_count
74
+
75
+
76
+ def move_selection(current_index: int, step: int) -> int:
77
+ """Move the home menu selection by one or more positions."""
78
+ return clamp_menu_index(current_index + step)
79
+
80
+
81
+ def get_menu_item(index: int) -> MenuItem:
82
+ """Return the menu item at a wrapped index."""
83
+ return HOME_MENU_ITEMS[clamp_menu_index(index)]
84
+
85
+
86
+ def select_menu_item(index: int) -> MenuSelection:
87
+ """Return the selection result for the current menu item."""
88
+ item = get_menu_item(index)
89
+ return MenuSelection(item=item, should_exit=item.label == "Exit")
coffee_cli/order.py ADDED
@@ -0,0 +1,136 @@
1
+ """Coffee ordering state and pricing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from typing import Literal
7
+
8
+ OrderStep = Literal["drink", "size", "milk", "confirm"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class OrderOption:
13
+ """A selectable option in the guided order flow."""
14
+
15
+ label: str
16
+ detail: str
17
+ price_delta: int = 0
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class OrderDraft:
22
+ """A partially completed coffee order."""
23
+
24
+ drink: OrderOption | None = None
25
+ size: OrderOption | None = None
26
+ milk: OrderOption | None = None
27
+
28
+
29
+ DRINK_OPTIONS = (
30
+ OrderOption("Espresso", "Short, focused, and direct.", 350),
31
+ OrderOption("Latte", "Smooth espresso with steamed milk.", 450),
32
+ OrderOption("Cappuccino", "Balanced espresso, milk, and foam.", 425),
33
+ OrderOption("Mocha", "Espresso with chocolate and milk.", 500),
34
+ OrderOption("Americano", "Espresso stretched with hot water.", 375),
35
+ )
36
+
37
+ SIZE_OPTIONS = (
38
+ OrderOption("Small", "A quick cup for a short session.", 0),
39
+ OrderOption("Medium", "The default developer serving.", 75),
40
+ OrderOption("Large", "For long debugging sessions.", 125),
41
+ )
42
+
43
+ MILK_OPTIONS = (
44
+ OrderOption("Whole", "Classic dairy texture.", 0),
45
+ OrderOption("Oat", "Creamy non-dairy option.", 65),
46
+ OrderOption("Almond", "Nutty and light.", 55),
47
+ OrderOption("Soy", "Soft, steady, and familiar.", 45),
48
+ OrderOption("None", "Keep the drink black.", 0),
49
+ )
50
+
51
+ CONFIRM_OPTIONS = (
52
+ OrderOption("Confirm Order", "Finish this order and show the total."),
53
+ OrderOption("Start Over", "Clear the current order and choose again."),
54
+ OrderOption("Cancel", "Return to the home screen."),
55
+ )
56
+
57
+ ORDER_STEP_TITLES: dict[OrderStep, str] = {
58
+ "drink": "Choose Coffee",
59
+ "size": "Choose Size",
60
+ "milk": "Choose Milk",
61
+ "confirm": "Review Order",
62
+ }
63
+
64
+
65
+ def format_price(cents: int) -> str:
66
+ """Format cents as a simple dollar amount."""
67
+ dollars, cents_remainder = divmod(cents, 100)
68
+ return f"${dollars}.{cents_remainder:02d}"
69
+
70
+
71
+ def calculate_total(draft: OrderDraft) -> int:
72
+ """Calculate the current order total in cents."""
73
+ return sum(
74
+ option.price_delta
75
+ for option in (draft.drink, draft.size, draft.milk)
76
+ if option is not None
77
+ )
78
+
79
+
80
+ def get_order_options(step: OrderStep) -> tuple[OrderOption, ...]:
81
+ """Return selectable options for the current order step."""
82
+ match step:
83
+ case "drink":
84
+ return DRINK_OPTIONS
85
+ case "size":
86
+ return SIZE_OPTIONS
87
+ case "milk":
88
+ return MILK_OPTIONS
89
+ case "confirm":
90
+ return CONFIRM_OPTIONS
91
+
92
+
93
+ def move_order_selection(current_index: int, step: int, option_count: int) -> int:
94
+ """Move through the current order step options."""
95
+ if option_count < 1:
96
+ raise ValueError("order step must contain at least one option")
97
+ return (current_index + step) % option_count
98
+
99
+
100
+ def apply_order_choice(draft: OrderDraft, step: OrderStep, option_index: int) -> OrderDraft:
101
+ """Apply the selected option to the draft order."""
102
+ option = get_order_options(step)[option_index]
103
+ match step:
104
+ case "drink":
105
+ return replace(draft, drink=option)
106
+ case "size":
107
+ return replace(draft, size=option)
108
+ case "milk":
109
+ return replace(draft, milk=option)
110
+ case "confirm":
111
+ return draft
112
+
113
+
114
+ def next_order_step(step: OrderStep) -> OrderStep:
115
+ """Advance the guided order flow."""
116
+ match step:
117
+ case "drink":
118
+ return "size"
119
+ case "size":
120
+ return "milk"
121
+ case "milk" | "confirm":
122
+ return "confirm"
123
+
124
+
125
+ def previous_order_step(step: OrderStep) -> OrderStep | None:
126
+ """Move backward through the guided order flow."""
127
+ match step:
128
+ case "drink":
129
+ return None
130
+ case "size":
131
+ return "drink"
132
+ case "milk":
133
+ return "size"
134
+ case "confirm":
135
+ return "milk"
136
+
coffee_cli/personal.py ADDED
@@ -0,0 +1,166 @@
1
+ """Personal coffee data stored on disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from coffee_cli.order import OrderDraft, calculate_total
12
+
13
+ APP_DIR_NAME = "coffee-for-devs"
14
+ DATA_FILE_NAME = "personal.json"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SavedDrink:
19
+ """A favorite drink saved from an order."""
20
+
21
+ drink: str
22
+ size: str
23
+ milk: str
24
+ total: int
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class JournalEntry:
29
+ """A small journal entry created after an order."""
30
+
31
+ title: str
32
+ note: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PersonalData:
37
+ """Local personal data for Coffee CLI."""
38
+
39
+ favorites: tuple[SavedDrink, ...] = ()
40
+ journal: tuple[JournalEntry, ...] = ()
41
+ order_history: tuple[SavedDrink, ...] = ()
42
+
43
+
44
+ def default_data_path() -> Path:
45
+ """Return the default local data file path."""
46
+ base_dir = os.environ.get("XDG_DATA_HOME")
47
+ if base_dir:
48
+ return Path(base_dir) / APP_DIR_NAME / DATA_FILE_NAME
49
+ return Path.home() / ".local" / "share" / APP_DIR_NAME / DATA_FILE_NAME
50
+
51
+
52
+ def draft_to_saved_drink(draft: OrderDraft) -> SavedDrink:
53
+ """Convert a completed order draft into persisted drink data."""
54
+ return SavedDrink(
55
+ drink=draft.drink.label if draft.drink else "Coffee",
56
+ size=draft.size.label if draft.size else "Standard",
57
+ milk=draft.milk.label if draft.milk else "None",
58
+ total=calculate_total(draft),
59
+ )
60
+
61
+
62
+ def add_favorite(data: PersonalData, drink: SavedDrink) -> PersonalData:
63
+ """Add a favorite drink if it is not already saved."""
64
+ if drink in data.favorites:
65
+ return data
66
+ return PersonalData(
67
+ favorites=(*data.favorites, drink),
68
+ journal=data.journal,
69
+ order_history=data.order_history,
70
+ )
71
+
72
+
73
+ def add_order_history(data: PersonalData, drink: SavedDrink) -> PersonalData:
74
+ """Add a completed drink to order history."""
75
+ return PersonalData(
76
+ favorites=data.favorites,
77
+ journal=data.journal,
78
+ order_history=(*data.order_history, drink),
79
+ )
80
+
81
+
82
+ def add_journal_entry(data: PersonalData, entry: JournalEntry) -> PersonalData:
83
+ """Add a journal entry."""
84
+ return PersonalData(
85
+ favorites=data.favorites,
86
+ journal=(*data.journal, entry),
87
+ order_history=data.order_history,
88
+ )
89
+
90
+
91
+ def make_journal_entry(drink: SavedDrink) -> JournalEntry:
92
+ """Create a simple automatic journal entry for a completed order."""
93
+ return JournalEntry(
94
+ title=f"{drink.size} {drink.drink}",
95
+ note=f"Ordered with {drink.milk} milk for {format_cents(drink.total)}.",
96
+ )
97
+
98
+
99
+ def favorite_labels(data: PersonalData) -> tuple[str, ...]:
100
+ """Return display labels for saved favorites."""
101
+ return tuple(
102
+ f"{drink.size} {drink.drink} with {drink.milk} ({format_cents(drink.total)})"
103
+ for drink in data.favorites
104
+ )
105
+
106
+
107
+ def journal_labels(data: PersonalData) -> tuple[str, ...]:
108
+ """Return display labels for journal entries."""
109
+ return tuple(f"{entry.title}: {entry.note}" for entry in data.journal)
110
+
111
+
112
+ def stats_summary(data: PersonalData) -> dict[str, int | str]:
113
+ """Return simple statistics for personal coffee data."""
114
+ total_orders = len(data.order_history)
115
+ total_spent = sum(drink.total for drink in data.order_history)
116
+ favorite_count = len(data.favorites)
117
+ most_recent = data.order_history[-1].drink if data.order_history else "None"
118
+ return {
119
+ "total_orders": total_orders,
120
+ "total_spent": total_spent,
121
+ "favorite_count": favorite_count,
122
+ "most_recent": most_recent,
123
+ }
124
+
125
+
126
+ def load_personal_data(path: Path | None = None) -> PersonalData:
127
+ """Load personal data from disk."""
128
+ data_path = path or default_data_path()
129
+ if not data_path.exists():
130
+ return PersonalData()
131
+
132
+ with data_path.open("r", encoding="utf-8") as file:
133
+ payload = json.load(file)
134
+ return personal_data_from_dict(payload)
135
+
136
+
137
+ def save_personal_data(data: PersonalData, path: Path | None = None) -> None:
138
+ """Save personal data to disk."""
139
+ data_path = path or default_data_path()
140
+ data_path.parent.mkdir(parents=True, exist_ok=True)
141
+ with data_path.open("w", encoding="utf-8") as file:
142
+ json.dump(personal_data_to_dict(data), file, indent=2)
143
+
144
+
145
+ def personal_data_to_dict(data: PersonalData) -> dict[str, list[dict[str, Any]]]:
146
+ """Convert personal data into JSON-safe structures."""
147
+ return {
148
+ "favorites": [asdict(drink) for drink in data.favorites],
149
+ "journal": [asdict(entry) for entry in data.journal],
150
+ "order_history": [asdict(drink) for drink in data.order_history],
151
+ }
152
+
153
+
154
+ def personal_data_from_dict(payload: dict[str, Any]) -> PersonalData:
155
+ """Create personal data from JSON-loaded structures."""
156
+ return PersonalData(
157
+ favorites=tuple(SavedDrink(**item) for item in payload.get("favorites", [])),
158
+ journal=tuple(JournalEntry(**item) for item in payload.get("journal", [])),
159
+ order_history=tuple(SavedDrink(**item) for item in payload.get("order_history", [])),
160
+ )
161
+
162
+
163
+ def format_cents(cents: int) -> str:
164
+ """Format cents as a simple dollar amount."""
165
+ dollars, cents_remainder = divmod(cents, 100)
166
+ return f"${dollars}.{cents_remainder:02d}"
coffee_cli/ui_text.py ADDED
@@ -0,0 +1,215 @@
1
+ """Pure text rendering helpers for the Coffee CLI UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from coffee_cli.brewing import BrewStage, render_progress_bar
6
+ from coffee_cli.home import HOME_MENU_ITEMS, MenuItem
7
+ from coffee_cli.order import (
8
+ ORDER_STEP_TITLES,
9
+ OrderDraft,
10
+ OrderOption,
11
+ OrderStep,
12
+ calculate_total,
13
+ format_price,
14
+ )
15
+ from coffee_cli.personal import PersonalData, favorite_labels, journal_labels, stats_summary
16
+
17
+ STEAM_FRAMES = (
18
+ "\n".join(
19
+ (
20
+ " *",
21
+ " %",
22
+ " %#",
23
+ " %### #",
24
+ " %#### %#",
25
+ )
26
+ ),
27
+ "\n".join(
28
+ (
29
+ " %",
30
+ " %#",
31
+ " %### #",
32
+ " %##### %#%",
33
+ " #### ###",
34
+ )
35
+ ),
36
+ "\n".join(
37
+ (
38
+ " *",
39
+ " %#",
40
+ " %#### #",
41
+ " ##### %#%",
42
+ " #%%",
43
+ )
44
+ ),
45
+ )
46
+
47
+
48
+ def render_hero(steam_frame: str) -> str:
49
+ """Render the opening banner."""
50
+ return (
51
+ "╔════════════════════════════════════════════════╗\n"
52
+ "║ COFFEE FOR DEVS ║\n"
53
+ "╚════════════════════════════════════════════════╝\n\n"
54
+ f"{steam_frame}\n"
55
+ " ####################################\n"
56
+ " ####################################%\n"
57
+ " ################################################\n"
58
+ " ###### #######################################\n"
59
+ " ####% ######################################\n"
60
+ " #### ######################################\n"
61
+ " ##### #####################################\n"
62
+ " ###### *###################################%\n"
63
+ " ####### ###################################\n"
64
+ " ########% ###############################%\n"
65
+ " #####################################\n"
66
+ " #%%############################\n"
67
+ " #########################\n"
68
+ " #####################%\n"
69
+ " %###############%\n\n"
70
+ " (@) (@) (@) (@)\n"
71
+ " coffee beans for focused work\n\n"
72
+ " Brew Better. Code Better."
73
+ )
74
+
75
+
76
+ def render_menu(items: tuple[MenuItem, ...], selected_index: int) -> str:
77
+ """Render the home menu with a single active item."""
78
+ lines = ["Home", ""]
79
+ for index, item in enumerate(items):
80
+ prefix = "►" if index == selected_index else " "
81
+ lines.append(f"{prefix} {item.label}")
82
+ lines.extend(("", "↑/k Up ↓/j Down Enter Select q Quit"))
83
+ return "\n".join(lines)
84
+
85
+
86
+ def render_summary(selected_item: MenuItem, message: str) -> str:
87
+ """Render the right-hand summary panel."""
88
+ return (
89
+ "Selected\n"
90
+ f"{selected_item.label}\n\n"
91
+ "What this does\n"
92
+ f"{selected_item.detail}\n\n"
93
+ "Sprint focus\n"
94
+ "Playable home screen with visible navigation.\n\n"
95
+ f"Status: {message}"
96
+ )
97
+
98
+
99
+ def render_personal_panel(title: str, lines: tuple[str, ...], empty_message: str) -> str:
100
+ """Render a personal tools panel."""
101
+ body = lines if lines else (empty_message,)
102
+ return "\n".join((title, "", *body, "", "Esc Back q Quit"))
103
+
104
+
105
+ def render_personal_summary(data: PersonalData, message: str) -> str:
106
+ """Render a summary for personal coffee data."""
107
+ stats = stats_summary(data)
108
+ return (
109
+ "Personal Coffee\n\n"
110
+ f"Favorites: {stats['favorite_count']}\n"
111
+ f"Orders: {stats['total_orders']}\n"
112
+ f"Spent: {format_price(int(stats['total_spent']))}\n"
113
+ f"Recent: {stats['most_recent']}\n\n"
114
+ f"Status: {message}"
115
+ )
116
+
117
+
118
+ def render_favorites_panel(data: PersonalData) -> str:
119
+ """Render saved favorite drinks."""
120
+ return render_personal_panel(
121
+ "My Favorites",
122
+ favorite_labels(data),
123
+ "No favorites saved yet. Confirm an order to save one.",
124
+ )
125
+
126
+
127
+ def render_journal_panel(data: PersonalData) -> str:
128
+ """Render coffee journal entries."""
129
+ return render_personal_panel(
130
+ "Coffee Journal",
131
+ journal_labels(data),
132
+ "No journal entries yet. Completed orders create entries.",
133
+ )
134
+
135
+
136
+ def render_statistics_panel(data: PersonalData) -> str:
137
+ """Render simple statistics."""
138
+ stats = stats_summary(data)
139
+ lines = (
140
+ f"Total orders: {stats['total_orders']}",
141
+ f"Total spent: {format_price(int(stats['total_spent']))}",
142
+ f"Favorites: {stats['favorite_count']}",
143
+ f"Most recent: {stats['most_recent']}",
144
+ )
145
+ return render_personal_panel("Statistics", lines, "No statistics yet.")
146
+
147
+
148
+ def render_home_screen(steam_frame: str, selected_index: int, message: str) -> str:
149
+ """Render the complete home screen as plain text for tests and snapshots."""
150
+ selected_item = HOME_MENU_ITEMS[selected_index]
151
+ return "\n\n".join(
152
+ (
153
+ render_hero(steam_frame),
154
+ render_menu(HOME_MENU_ITEMS, selected_index),
155
+ render_summary(selected_item, message),
156
+ )
157
+ )
158
+
159
+
160
+ def render_order_menu(
161
+ step: OrderStep,
162
+ options: tuple[OrderOption, ...],
163
+ selected_index: int,
164
+ ) -> str:
165
+ """Render the current order step options."""
166
+ lines = [f"Order > {ORDER_STEP_TITLES[step]}", ""]
167
+ for index, option in enumerate(options):
168
+ prefix = "►" if index == selected_index else " "
169
+ price = f" {format_price(option.price_delta)}" if option.price_delta else ""
170
+ lines.append(f"{prefix} {option.label}{price}")
171
+ lines.append(f" {option.detail}")
172
+ lines.extend(("", "↑/k Up ↓/j Down Enter Select Esc Back q Quit"))
173
+ return "\n".join(lines)
174
+
175
+
176
+ def render_order_summary(draft: OrderDraft, message: str) -> str:
177
+ """Render the live order summary."""
178
+ drink = draft.drink.label if draft.drink else "Not selected"
179
+ size = draft.size.label if draft.size else "Not selected"
180
+ milk = draft.milk.label if draft.milk else "Not selected"
181
+ return (
182
+ "Order Summary\n\n"
183
+ f"Coffee: {drink}\n"
184
+ f"Size: {size}\n"
185
+ f"Milk: {milk}\n\n"
186
+ f"Total: {format_price(calculate_total(draft))}\n\n"
187
+ f"Status: {message}"
188
+ )
189
+
190
+
191
+ def render_brewing_panel(stage: BrewStage, is_complete: bool) -> str:
192
+ """Render the brewing animation panel."""
193
+ footer = "Enter Return Home q Quit" if is_complete else "Brewing in progress..."
194
+ return (
195
+ "Brewing\n\n"
196
+ f"{stage.label}...\n"
197
+ f"{render_progress_bar(stage.progress)}\n\n"
198
+ f"{stage.detail}\n\n"
199
+ f"{footer}"
200
+ )
201
+
202
+
203
+ def render_brewing_summary(draft: OrderDraft, stage: BrewStage) -> str:
204
+ """Render order details while brewing."""
205
+ drink = draft.drink.label if draft.drink else "Coffee"
206
+ size = draft.size.label if draft.size else "Standard"
207
+ milk = draft.milk.label if draft.milk else "None"
208
+ return (
209
+ "Final Order\n\n"
210
+ f"{size} {drink}\n"
211
+ f"Milk: {milk}\n"
212
+ f"Total: {format_price(calculate_total(draft))}\n\n"
213
+ f"Now: {stage.label}\n\n"
214
+ "Milestone 3 turns confirmation into a visible brewing moment."
215
+ )
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: coffee-cli
3
+ Version: 0.1.0
4
+ Summary: A polished Python terminal UI for coffee-themed developer workflows.
5
+ Project-URL: Homepage, https://github.com/josephforson285/coffee-for-devs
6
+ Project-URL: Issues, https://github.com/josephforson285/coffee-for-devs/issues
7
+ Author: Coffee CLI Contributors
8
+ License: MIT
9
+ Keywords: cli,coffee,terminal,textual,tui
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development
16
+ Classifier: Topic :: Terminals
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: textual<1.0,>=0.68
19
+ Provides-Extra: dev
20
+ Requires-Dist: anyio>=4.4; extra == 'dev'
21
+ Requires-Dist: build>=1.2; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.2; extra == 'dev'
24
+ Requires-Dist: ruff>=0.5; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Coffee CLI
28
+
29
+ Coffee CLI is a Python terminal UI application for developers who want a polished, installable `coffee` command with personality. The app is built as a modern Python package and is intended to grow into a showcase project for terminal UX, testing, CI/CD, and professional packaging.
30
+
31
+ ## Project goals
32
+
33
+ - Launch with `coffee` after installation.
34
+ - Use `Textual` for a polished TUI.
35
+ - Keep the codebase educational and production-leaning.
36
+ - Validate every change with tests, linting, and CI.
37
+
38
+ ## Local environment
39
+
40
+ This project is being developed inside:
41
+
42
+ - project root: `/home/redsteam/Desktop/bear/ML/coffee-for-devs`
43
+ - virtual environment: `/home/redsteam/Desktop/bear/ML/MLprojs`
44
+
45
+ Activate the environment:
46
+
47
+ ```bash
48
+ source /home/redsteam/Desktop/bear/ML/MLprojs/bin/activate
49
+ ```
50
+
51
+ ## First-time setup
52
+
53
+ Install the project and developer tools into the virtual environment:
54
+
55
+ ```bash
56
+ python -m pip install -e ".[dev]"
57
+ ```
58
+
59
+ Run the quality checks:
60
+
61
+ ```bash
62
+ ruff check .
63
+ pytest
64
+ python -m build
65
+ ```
66
+
67
+ Start the app:
68
+
69
+ ```bash
70
+ coffee
71
+ ```
72
+
73
+ ## Install from a wheel
74
+
75
+ Build the package:
76
+
77
+ ```bash
78
+ python -m build
79
+ ```
80
+
81
+ Install the generated wheel into a fresh environment:
82
+
83
+ ```bash
84
+ python -m pip install dist/coffee_cli-0.1.0-py3-none-any.whl
85
+ coffee
86
+ ```
87
+
88
+ This is the local release-candidate check before publishing to PyPI.
89
+
90
+ ## Release publishing
91
+
92
+ Publishing is handled by the manual GitHub Actions workflow in `.github/workflows/publish.yml`.
93
+ Use `target=testpypi` first, verify the package, then publish to PyPI from a version tag such as
94
+ `v0.1.0`.
95
+
96
+ ## Current scope
97
+
98
+ Coffee CLI currently includes:
99
+
100
+ - modern `pyproject.toml` packaging
101
+ - `src/` layout
102
+ - `coffee` console script entry point
103
+ - first Textual app skeleton with animated ASCII steam
104
+ - order flow, brewing flow, personal favorites, journal, and statistics
105
+ - tested package structure
106
+ - GitHub Actions CI workflow
107
+
108
+ See [docs/roadmap.md](docs/roadmap.md) for the planned sprint breakdown, [docs/milestones.md](docs/milestones.md) for runnable milestones, [docs/release-checklist.md](docs/release-checklist.md) for release steps, and [CONTRIBUTING.md](CONTRIBUTING.md) for the Git workflow.
@@ -0,0 +1,13 @@
1
+ coffee_cli/__init__.py,sha256=7tcb4C1Zo7GNcknR5YW1JtKdmwQPk_YH1h0gbmgoz4A,77
2
+ coffee_cli/__main__.py,sha256=Ym6aA8IYkC_fshum4ZmaneNPIOj7emPtcD4HTtPGCFE,143
3
+ coffee_cli/app.py,sha256=Ce0UlOQAklZ1mdhNZaXsfX9PMMKaKuaofMO0AHwQfKg,11413
4
+ coffee_cli/brewing.py,sha256=9_zUzl1YBgYddeyGmgVmZibhlJyyCrPS5lfc3xRJR3o,1452
5
+ coffee_cli/cli.py,sha256=OpIqw6iaMQjx-7Co-m4WbQH88QxAwQIFyhjsXeiZO84,1060
6
+ coffee_cli/home.py,sha256=jKaHpjnxcWJeoJC8PMn_-ZBzgBFDypF-a87zwS79f5A,2547
7
+ coffee_cli/order.py,sha256=ujhxMXcGDhtiytrIo0EViVrU5YTWZkZV7KcQ0zIpsvc,3984
8
+ coffee_cli/personal.py,sha256=OLK4cfvOAPgqmd2eBJiz9Y7vsvSaBUE_1dGsWdrcA1s,5293
9
+ coffee_cli/ui_text.py,sha256=v_QH9pjW1L1ZJrPCOr4epkpP4PcC9l-bXc83_3OGJ4s,7718
10
+ coffee_cli-0.1.0.dist-info/METADATA,sha256=Cv0Q9u6ZXqoBNUwGnHksddK8IuKjaDeV5Ccp4ULvAXg,3152
11
+ coffee_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ coffee_cli-0.1.0.dist-info/entry_points.txt,sha256=no4mEcbp7mbPtKxKPlpe4Cj3r-uZwe1C3kfy_jZIXF8,47
13
+ coffee_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ coffee = coffee_cli.cli:main