milo-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.
milo/flow.py ADDED
@@ -0,0 +1,146 @@
1
+ """Declarative screen state machine with >> operator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from milo._errors import ErrorCode, FlowError
10
+ from milo._types import Action, Quit, ReducerResult, Transition
11
+
12
+
13
+ def _default_transition(from_name: str, to_name: str) -> Transition:
14
+ return Transition(from_screen=from_name, to_screen=to_name, on_action="@@NAVIGATE")
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class FlowScreen:
19
+ """A screen in a flow with its template and reducer."""
20
+
21
+ name: str
22
+ template: str
23
+ reducer: Callable
24
+
25
+ def __rshift__(self, other: FlowScreen) -> Flow:
26
+ """screen_a >> screen_b creates a Flow."""
27
+ return Flow(
28
+ screens=(self, other),
29
+ transitions=(_default_transition(self.name, other.name),),
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class FlowState:
35
+ """Runtime state for a flow."""
36
+
37
+ current_screen: str
38
+ screen_states: dict[str, Any]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Flow:
43
+ """Declarative multi-screen state machine."""
44
+
45
+ screens: tuple[FlowScreen, ...]
46
+ transitions: tuple[Transition, ...]
47
+
48
+ def __rshift__(self, other: FlowScreen) -> Flow:
49
+ """flow >> screen_c extends the flow."""
50
+ last = self.screens[-1]
51
+ return Flow(
52
+ screens=(*self.screens, other),
53
+ transitions=(*self.transitions, _default_transition(last.name, other.name)),
54
+ )
55
+
56
+ @classmethod
57
+ def from_screens(cls, *screens: FlowScreen) -> Flow:
58
+ """Create a flow from an ordered sequence of screens."""
59
+ if len(screens) < 2:
60
+ raise FlowError(ErrorCode.FLW_SCREEN, "Flow requires at least 2 screens")
61
+ transitions = tuple(
62
+ _default_transition(screens[i].name, screens[i + 1].name)
63
+ for i in range(len(screens) - 1)
64
+ )
65
+ return cls(screens=screens, transitions=transitions)
66
+
67
+ def with_transition(self, from_screen: str, to_screen: str, *, on: str) -> Flow:
68
+ """Add a custom transition."""
69
+ # Validate screen names
70
+ names = {s.name for s in self.screens}
71
+ if from_screen not in names:
72
+ raise FlowError(ErrorCode.FLW_SCREEN, f"Unknown screen: {from_screen}")
73
+ if to_screen not in names:
74
+ raise FlowError(ErrorCode.FLW_SCREEN, f"Unknown screen: {to_screen}")
75
+ return Flow(
76
+ screens=self.screens,
77
+ transitions=(*self.transitions, Transition(from_screen, to_screen, on)),
78
+ )
79
+
80
+ def build_reducer(self) -> Callable:
81
+ """Build a combined reducer that routes actions to the current screen's reducer."""
82
+ screen_map = {s.name: s for s in self.screens}
83
+ transition_map: dict[tuple[str, str], str] = {}
84
+ for t in self.transitions:
85
+ transition_map[(t.from_screen, t.on_action)] = t.to_screen
86
+
87
+ def flow_reducer(state: FlowState | None, action: Action) -> FlowState:
88
+ if state is None:
89
+ first = self.screens[0]
90
+ initial_states = {s.name: s.reducer(None, Action("@@INIT")) for s in self.screens}
91
+ return FlowState(
92
+ current_screen=first.name,
93
+ screen_states=initial_states,
94
+ )
95
+
96
+ current = state.current_screen
97
+
98
+ # Check for transitions
99
+ target = transition_map.get((current, action.type))
100
+ if target is None and action.type == "@@NAVIGATE" and action.payload:
101
+ target = action.payload if action.payload in screen_map else None
102
+
103
+ if target is not None and target in screen_map:
104
+ return FlowState(
105
+ current_screen=target,
106
+ screen_states=state.screen_states,
107
+ )
108
+
109
+ # Route action to current screen's reducer
110
+ screen = screen_map.get(current)
111
+ if screen is None:
112
+ raise FlowError(ErrorCode.FLW_SCREEN, f"Unknown screen: {current}")
113
+
114
+ result = screen.reducer(state.screen_states.get(current), action)
115
+
116
+ # Unwrap Quit/ReducerResult to propagate sagas
117
+ sagas = ()
118
+ quit_signal: Quit | None = None
119
+ if isinstance(result, Quit):
120
+ quit_signal = result
121
+ new_screen_state = result.state
122
+ sagas = result.sagas
123
+ elif isinstance(result, ReducerResult):
124
+ new_screen_state = result.state
125
+ sagas = result.sagas
126
+ else:
127
+ new_screen_state = result
128
+
129
+ new_states = {**state.screen_states, current: new_screen_state}
130
+ flow_state = FlowState(
131
+ current_screen=current,
132
+ screen_states=new_states,
133
+ )
134
+
135
+ if quit_signal is not None:
136
+ return Quit(state=flow_state, code=quit_signal.code, sagas=sagas)
137
+ if sagas:
138
+ return ReducerResult(state=flow_state, sagas=sagas)
139
+ return flow_state
140
+
141
+ return flow_reducer
142
+
143
+ @property
144
+ def template_map(self) -> dict[str, str]:
145
+ """Map screen names to template names."""
146
+ return {s.name: s.template for s in self.screens}
milo/form.py ADDED
@@ -0,0 +1,277 @@
1
+ """Form fields and form reducer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import replace
7
+ from typing import Any
8
+
9
+ from milo._types import (
10
+ Action,
11
+ FieldSpec,
12
+ FieldState,
13
+ FieldType,
14
+ FormState,
15
+ Key,
16
+ SpecialKey,
17
+ )
18
+ from milo.input._platform import is_tty
19
+
20
+
21
+ def _make_initial_fields(specs: tuple[FieldSpec, ...]) -> tuple[FieldState, ...]:
22
+ """Create initial field states from specs."""
23
+ fields = []
24
+ for i, spec in enumerate(specs):
25
+ default = spec.default if spec.default is not None else ""
26
+ if spec.field_type == FieldType.CONFIRM:
27
+ default = spec.default if spec.default is not None else False
28
+ elif spec.field_type == FieldType.SELECT:
29
+ default = spec.default if spec.default is not None else 0
30
+ fields.append(
31
+ FieldState(
32
+ value=default,
33
+ cursor=len(str(default)) if isinstance(default, str) else 0,
34
+ focused=(i == 0),
35
+ )
36
+ )
37
+ return tuple(fields)
38
+
39
+
40
+ def form_reducer(state: FormState | None, action: Action) -> FormState:
41
+ """Reducer for form state."""
42
+ if state is None:
43
+ return FormState()
44
+
45
+ if state.submitted:
46
+ return state
47
+
48
+ if action.type == "@@INIT" and action.payload and isinstance(action.payload, tuple):
49
+ specs = action.payload
50
+ return FormState(
51
+ fields=_make_initial_fields(specs),
52
+ specs=specs,
53
+ active_index=0,
54
+ )
55
+
56
+ if action.type != "@@KEY":
57
+ return state
58
+
59
+ key: Key = action.payload
60
+ if not isinstance(key, Key):
61
+ return state
62
+
63
+ idx = state.active_index
64
+ if idx >= len(state.fields) or idx >= len(state.specs):
65
+ return state
66
+
67
+ field = state.fields[idx]
68
+ spec = state.specs[idx]
69
+
70
+ # Tab / Enter: move to next field or submit
71
+ if key.name == SpecialKey.TAB or key.name == SpecialKey.ENTER:
72
+ if key.name == SpecialKey.ENTER and spec.field_type != FieldType.TEXT:
73
+ pass # Enter confirms select/confirm fields too
74
+ # Validate current field
75
+ if spec.validator:
76
+ ok, err = spec.validator(field.value)
77
+ if not ok:
78
+ new_field = replace(field, error=err)
79
+ fields = _replace_field(state.fields, idx, new_field)
80
+ return replace(state, fields=fields)
81
+
82
+ if idx < len(state.fields) - 1:
83
+ # Move to next field
84
+ fields = _replace_field(
85
+ state.fields,
86
+ idx,
87
+ replace(field, focused=False, error=""),
88
+ )
89
+ next_field = replace(state.fields[idx + 1], focused=True)
90
+ fields = _replace_field(fields, idx + 1, next_field)
91
+ return replace(state, fields=fields, active_index=idx + 1)
92
+ else:
93
+ # Submit
94
+ return replace(state, submitted=True)
95
+
96
+ # Shift+Tab: move to previous field
97
+ if key.name == SpecialKey.TAB and key.shift:
98
+ if idx > 0:
99
+ fields = _replace_field(state.fields, idx, replace(field, focused=False))
100
+ prev_field = replace(state.fields[idx - 1], focused=True)
101
+ fields = _replace_field(fields, idx - 1, prev_field)
102
+ return replace(state, fields=fields, active_index=idx - 1)
103
+ return state
104
+
105
+ # Field-type specific handling
106
+ match spec.field_type:
107
+ case FieldType.TEXT | FieldType.PASSWORD:
108
+ new_field = _handle_text_key(field, key)
109
+ case FieldType.SELECT:
110
+ new_field = _handle_select_key(field, key, spec)
111
+ case FieldType.CONFIRM:
112
+ new_field = _handle_confirm_key(field, key)
113
+ case _:
114
+ new_field = field
115
+
116
+ if new_field is not field:
117
+ fields = _replace_field(state.fields, idx, new_field)
118
+ return replace(state, fields=fields)
119
+
120
+ return state
121
+
122
+
123
+ def _handle_text_key(field: FieldState, key: Key) -> FieldState:
124
+ """Handle keypress for text/password fields."""
125
+ value = str(field.value)
126
+ cursor = field.cursor
127
+
128
+ if key.name == SpecialKey.BACKSPACE:
129
+ if cursor > 0:
130
+ value = value[: cursor - 1] + value[cursor:]
131
+ cursor -= 1
132
+ else:
133
+ return field
134
+ elif key.name == SpecialKey.DELETE:
135
+ if cursor < len(value):
136
+ value = value[:cursor] + value[cursor + 1 :]
137
+ else:
138
+ return field
139
+ elif key.name == SpecialKey.LEFT:
140
+ cursor = max(0, cursor - 1)
141
+ elif key.name == SpecialKey.RIGHT:
142
+ cursor = min(len(value), cursor + 1)
143
+ elif key.name == SpecialKey.HOME:
144
+ cursor = 0
145
+ elif key.name == SpecialKey.END:
146
+ cursor = len(value)
147
+ elif key.char and key.char.isprintable() and not key.ctrl and not key.alt:
148
+ value = value[:cursor] + key.char + value[cursor:]
149
+ cursor += 1
150
+ else:
151
+ return field
152
+
153
+ return replace(field, value=value, cursor=cursor, error="")
154
+
155
+
156
+ def _handle_select_key(field: FieldState, key: Key, spec: FieldSpec) -> FieldState:
157
+ """Handle keypress for select fields."""
158
+ idx = field.selected_index
159
+ count = len(spec.choices)
160
+
161
+ if key.name == SpecialKey.UP:
162
+ idx = (idx - 1) % count if count else 0
163
+ elif key.name == SpecialKey.DOWN:
164
+ idx = (idx + 1) % count if count else 0
165
+ else:
166
+ return field
167
+
168
+ return replace(field, selected_index=idx, value=spec.choices[idx] if spec.choices else "")
169
+
170
+
171
+ def _handle_confirm_key(field: FieldState, key: Key) -> FieldState:
172
+ """Handle keypress for confirm fields."""
173
+ if key.char in ("y", "Y"):
174
+ return replace(field, value=True)
175
+ elif key.char in ("n", "N"):
176
+ return replace(field, value=False)
177
+ elif key.name in (SpecialKey.LEFT, SpecialKey.RIGHT):
178
+ return replace(field, value=not field.value)
179
+ return field
180
+
181
+
182
+ def _replace_field(
183
+ fields: tuple[FieldState, ...], index: int, new: FieldState
184
+ ) -> tuple[FieldState, ...]:
185
+ """Return a new tuple with one field replaced."""
186
+ lst = list(fields)
187
+ lst[index] = new
188
+ return tuple(lst)
189
+
190
+
191
+ def make_form_reducer(*specs: FieldSpec, navigate_on_submit: bool = False) -> Callable:
192
+ """Create a form reducer pre-loaded with field specs.
193
+
194
+ The returned reducer initializes with the given fields on @@INIT,
195
+ so you don't need to pass specs via action payload.
196
+
197
+ If navigate_on_submit=True, dispatches @@NAVIGATE when the form
198
+ is submitted — useful in flows where the next screen should appear
199
+ automatically after form completion.
200
+ """
201
+ from milo._types import Put, ReducerResult
202
+
203
+ initial = FormState(
204
+ fields=_make_initial_fields(specs),
205
+ specs=specs,
206
+ active_index=0,
207
+ )
208
+
209
+ def _navigate_saga():
210
+ yield Put(Action("@@NAVIGATE"))
211
+
212
+ def reducer(state: FormState | None, action: Action) -> FormState | ReducerResult:
213
+ if state is None:
214
+ return initial
215
+ new_state = form_reducer(state, action)
216
+ if navigate_on_submit and not state.submitted and new_state.submitted:
217
+ return ReducerResult(state=new_state, sagas=(_navigate_saga,))
218
+ return new_state
219
+
220
+ return reducer
221
+
222
+
223
+ def form(
224
+ *specs: FieldSpec,
225
+ env: Any = None,
226
+ ) -> dict[str, Any]:
227
+ """Run an interactive form, return field values.
228
+
229
+ Falls back to input() if not a TTY.
230
+ """
231
+ if not is_tty():
232
+ return _form_fallback(specs)
233
+
234
+ from milo.app import App
235
+
236
+ initial = FormState(
237
+ fields=_make_initial_fields(specs),
238
+ specs=specs,
239
+ active_index=0,
240
+ )
241
+ # Focus the first field
242
+ if initial.fields:
243
+ fields = list(initial.fields)
244
+ fields[0] = replace(fields[0], focused=True)
245
+ initial = replace(initial, fields=tuple(fields))
246
+
247
+ app = App(
248
+ template="form.kida",
249
+ reducer=form_reducer,
250
+ initial_state=initial,
251
+ env=env,
252
+ )
253
+ final: FormState = app.run()
254
+ return {spec.name: field.value for spec, field in zip(specs, final.fields, strict=False)}
255
+
256
+
257
+ def _form_fallback(specs: tuple[FieldSpec, ...] | tuple) -> dict[str, Any]:
258
+ """Non-TTY fallback using input()."""
259
+ values: dict[str, Any] = {}
260
+ for spec in specs:
261
+ match spec.field_type:
262
+ case FieldType.CONFIRM:
263
+ raw = input(f"{spec.label} (y/n): ").strip().lower()
264
+ values[spec.name] = raw in ("y", "yes")
265
+ case FieldType.SELECT:
266
+ print(f"{spec.label}:")
267
+ for i, choice in enumerate(spec.choices):
268
+ print(f" {i + 1}. {choice}")
269
+ raw = input("Choice: ").strip()
270
+ try:
271
+ idx = int(raw) - 1
272
+ values[spec.name] = spec.choices[idx]
273
+ except ValueError, IndexError:
274
+ values[spec.name] = spec.choices[0] if spec.choices else ""
275
+ case _:
276
+ values[spec.name] = input(f"{spec.label}: ").strip()
277
+ return values