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 +6 -0
- coffee_cli/__main__.py +7 -0
- coffee_cli/app.py +344 -0
- coffee_cli/brewing.py +49 -0
- coffee_cli/cli.py +41 -0
- coffee_cli/home.py +89 -0
- coffee_cli/order.py +136 -0
- coffee_cli/personal.py +166 -0
- coffee_cli/ui_text.py +215 -0
- coffee_cli-0.1.0.dist-info/METADATA +108 -0
- coffee_cli-0.1.0.dist-info/RECORD +13 -0
- coffee_cli-0.1.0.dist-info/WHEEL +4 -0
- coffee_cli-0.1.0.dist-info/entry_points.txt +2 -0
coffee_cli/__init__.py
ADDED
coffee_cli/__main__.py
ADDED
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,,
|