Coverage for view / ui.py: 100.00%
267 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 00:07 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 00:07 +0000
1import time
2from rich.console import Console
3from rich.panel import Panel
4from rich.text import Text
5from rich.progress import (
6 Progress,
7 TextColumn,
8 BarColumn,
9 TimeRemainingColumn,
10 SpinnerColumn
11)
12from rich.live import Live
13from contextlib import contextmanager
14from rich.table import Table
15from rich.align import Align
16from typing import Optional
17from dataclasses import dataclass
18from view.output_format import OutputFormat
19from view.merge_mode import MergeMode
20from view.interface import UIInterface, ActionResult
21from view.keyboard import KeyboardKey
22from controller.workflow.state_machine import WorkflowState
23from sys import stdout
24from rich.box import HEAVY_HEAD, Box, MINIMAL
26HORIZONTALS_NO_BOTTOM = Box(
27 " ── \n"
28 " \n"
29 " ── \n"
30 " \n"
31 " ── \n"
32 " ── \n"
33 " \n"
34 " \n"
35)
37# Breadcrumb is now a simple list of strings representing the path from the
38# workflow state stack. Legacy `BreadcrumbState` dataclass was removed.
40class _StyledTimeMixin:
41 def __init__(self, style: str, attr: str, time_provider=time.perf_counter):
42 super().__init__()
43 self._style = style
44 self._attr = attr
45 self._time_provider = time_provider
47 def render(self, task):
48 value = getattr(task, self._attr)
49 if value is None:
50 return Text("00:00", style=self._style)
51 return Text(self._format_time(value), style=self._style)
53 @staticmethod
54 def _format_time(seconds: float) -> str:
55 secs = int(seconds)
56 hours, remainder = divmod(secs, 3600)
57 minutes, seconds = divmod(remainder, 60)
58 if hours:
59 return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
60 return f"{minutes:02d}:{seconds:02d}"
62class StyledTimeElapsedColumn(_StyledTimeMixin, TimeRemainingColumn):
63 def __init__(self, style: str, time_provider=None):
64 _StyledTimeMixin.__init__(self, style, "elapsed", time_provider=time_provider or time.perf_counter)
66 def render(self, task):
67 fields = getattr(task, "fields", {}) or {}
68 status = fields.get("status", "pending")
70 # When done, show final conversion time
71 if status == "done":
72 conversion_time = fields.get("conversion_time")
73 if conversion_time is None:
74 return Text("00:00", style=self._style)
75 return Text(f"{self._format_time(conversion_time)}", style=self._style)
77 # While converting, calculate elapsed from start_time
78 if status == "converting":
79 start_time = fields.get("start_time")
80 if start_time is not None:
81 elapsed = self._time_provider() - start_time
82 return Text(f"{self._format_time(elapsed)}", style=self._style)
84 # Pending or no start time
85 return Text("00:00", style=self._style)
87class StyledPercentageColumn(TextColumn):
88 def __init__(self, colors: dict):
89 super().__init__("{task.percentage:>3.0f}%")
90 self.colors = colors
92 def render(self, task):
93 fields = getattr(task, "fields", {}) or {}
94 status = fields.get("status", "pending")
95 percentage = f"{task.percentage:>3.0f}%"
97 if status == "done":
98 return Text.from_markup(f"[{self.colors['confirm']}]{percentage}[/]")
99 else:
100 return Text.from_markup(f"[{self.colors['accented']}]{percentage}[/]")
102class StyledDescriptionColumn(TextColumn):
103 def __init__(self, colors: dict):
104 super().__init__("[progress.description]{task.description}")
105 self.colors = colors
107 def render(self, task):
108 fields = getattr(task, "fields", {}) or {}
109 status = fields.get("status", "pending")
110 filename = fields.get("filename", "")
112 if status == "converting":
113 return Text.from_markup(f"[italic {self.colors['accented']}]converting {filename}[/]")
114 elif status == "done":
115 return Text.from_markup(f"[{self.colors['confirm']}]✓ {filename}[/]")
116 else:
117 return Text.from_markup(f"[{self.colors['subtle']}]{filename}[/]")
119class RetroCLI(UIInterface):
120 VERSION = "1.0.0"
122 def __init__(self, console: Optional[Console] = None, max_width: int = 120, colors: Optional[dict] = None, keyboard_reader=None):
123 self._keyboard_reader = keyboard_reader
124 self.max_width = max_width
125 self.console = console or Console()
126 default_colors = {
127 "logo": "#c25a1a", # Orange/rust for the ASCII logo
128 "primary": "#e9d8ff", # Soft purple for primary text and prompts
129 "secondary": "#52d9d8", # Teal for interactive options and highlights
130 "subtle": "#9aa0a6", # Soft grey for borders and subtle UI elements
131 "accented": "#c9a961", # Gold for progress indicators and emphasis
132 "confirm": "#6fc67c", # Light green for confirmations and success
133 "error": "#ff6b81", # Rosy red for error messages
134 }
135 self.colors = {**default_colors, **(colors or {})}
137 # Breadcrumb state (updated by controller on state transitions)
138 self.breadcrumb = []
140 @property
141 def keyboard_reader(self):
142 return self._keyboard_reader
144 @property
145 def panel_width(self) -> int:
146 """Compute constrained panel width based on terminal and max width."""
147 return min(self.max_width, self.console.size.width)
149 def _create_panel(self, content, title: Optional[str] = None, padding: Optional[tuple] = None, title_color: Optional[str] = "primary", **style_args) -> Panel:
150 """Create a styled panel with consistent settings."""
151 kwargs = {
152 "border_style": self.colors["subtle"],
153 "width": self.panel_width,
154 "box": HORIZONTALS_NO_BOTTOM,
155 }
156 if title:
157 kwargs["title"] = f"[{self.colors[title_color]}]\\[{title}][/ ]"
158 kwargs["title_align"] = "left"
159 if padding:
160 kwargs["padding"] = padding
161 return Panel(content, **(kwargs | style_args))
163 def _create_hint_panel(self, hints: str) -> Panel:
164 """Create a panel for keyboard navigation hints."""
165 return Panel(
166 f"[{self.colors['primary']}]{hints}[/]",
167 border_style=f"dim {self.colors["subtle"]}",
168 width=self.panel_width,
169 box=HEAVY_HEAD,
170 )
172 def _create_selection_table(self) -> Table:
173 """Create a pre-configured table for selection menus."""
174 table = Table(
175 show_header=False,
176 width=self.panel_width - 4,
177 show_edge=False,
178 )
179 table.add_column("option", style=self.colors["subtle"])
180 return table
182 def _render_radio_row(self, is_current: bool, display_name: str, hint: str) -> str:
183 """Render a radio button row for selection menus."""
184 if is_current:
185 marker = f"[{self.colors['secondary']}]►[/]"
186 radio = f"[{self.colors['secondary']}]●[/]"
187 text = f"[{self.colors['secondary']}]{display_name}[/] [{self.colors['secondary']}]{hint}[/]"
188 else:
189 marker = " "
190 radio = "○"
191 text = f"[{self.colors['primary']}]{display_name}[/] {hint}"
192 return f"{marker} {radio} {text}"
194 def _radio_select(self, options: list, title: str) -> ActionResult:
195 """Generic radio-button selection menu.
197 Args:
198 options: List of enum values with display_name and display_hint properties
199 title: Panel title text
201 Returns:
202 Selected option from the list
203 """
204 current_index = 0
205 hints = f"[{self.colors['secondary']}]⬆︎ /⬇︎[/] :navigate [{self.colors['secondary']}][ENTER][/]:confirm [{self.colors['secondary']}][BACKSPACE][/]:back [{self.colors['secondary']}][Q][/]:quit"
207 while True:
208 self.clear_and_show_header()
209 table = self._create_selection_table()
210 for i, option in enumerate(options):
211 table.add_row(self._render_radio_row(
212 i == current_index,
213 option.display_name,
214 option.display_hint
215 ))
217 self.print_center(self._create_panel(table, title=title, padding=(1, 0, 1, 0)))
218 self.print_center(self._create_hint_panel(hints))
220 token = self.keyboard_reader()
222 if token.key == KeyboardKey.BACKSPACE:
223 return ActionResult.back()
225 if token.key == KeyboardKey.CHAR and token.char == "q":
226 return ActionResult.terminate()
228 if token.key == KeyboardKey.UP:
229 current_index = (current_index - 1) % len(options)
230 elif token.key == KeyboardKey.DOWN:
231 current_index = (current_index + 1) % len(options)
232 elif token.key == KeyboardKey.ENTER:
233 return ActionResult.value(options[current_index])
235 def print_center(self, renderable):
236 """Print a renderable centered within the configured console width."""
237 term_width = self.console.size.width
238 self.console.print(Align.center(renderable, width=term_width))
240 def input_center(self, prompt_symbol=">>", title = "", hint = ""):
241 left_padding = (self.console.size.width - self.panel_width) // 2 + 3
242 markup = (
243 f"[{self.colors['subtle']}]{hint}[/]\n\n"
244 f"[{self.colors['primary']}]{prompt_symbol}[/]"
245 )
246 self.print_center(self._create_panel(Text.from_markup(markup), title, padding=(1, 0, 0, 1)))
248 hints = f"[{self.colors['secondary']}][ENTER][/]:confirm [{self.colors['secondary']}][\Q][/]:quit"
249 self.print_center(self._create_hint_panel(hints))
251 # Some Magic to hijack and reposition the blinking cursos
252 stdout.write("\033[5A") # More Magic: move up 6 lines. The hight of the hints panel + padding
253 stdout.write(f"\033[{len(prompt_symbol) + left_padding}C") # move left
254 stdout.flush()
255 user_input = input("")
256 stdout.write("\033[5B") # Move Down 5 (past bottom border), not to override the above
257 stdout.flush()
258 return user_input
261 def clear_and_show_header(self):
262 """Clear screen and display header with breadcrumb navigation."""
263 self.console.clear()
264 self.draw_header()
265 self.draw_breadcrumb()
267 def draw_breadcrumb(self) -> None:
268 breadcrumb_text = Text()
269 last_index = len(self.breadcrumb) - 1
270 for i, label in enumerate(self.breadcrumb):
271 if i > 0:
272 breadcrumb_text.append(" >> ", style=self.colors["subtle"])
273 color = self.colors["secondary"] if i == last_index else self.colors["primary"]
274 breadcrumb_text.append(Text.from_markup(f"[{color}]{label}[/]"))
276 self.print_center(self._create_panel(breadcrumb_text, padding=(0, 0, 0, 0), box=MINIMAL))
278 def draw_header(self):
279 ascii_logo = """
280 ██╗ ██╗███████╗██╗ ██╗ ██╗ ██╗███╗ ███╗
281 ██║ ██║██╔════╝██║ ██║ ██║ ██║████╗ ████║
282 ██║ ██║█████╗ ██║ ██║ ██║ ██║██╔████╔██║
283 ╚██╗ ██╔╝██╔══╝ ██║ ██║ ██║ ██║██║╚██╔╝██║
284 ╚████╔╝ ███████╗███████╗███████╗╚██████╔╝██║ ╚═╝ ██║
285 ╚═══╝ ╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝
286 """
287 subtitle = f"[ epub | pdf -> txt ]"
288 logo_width = max(len(line) for line in ascii_logo.splitlines())
289 subtitle_width = len(subtitle) - 1
290 padding = (logo_width - subtitle_width) // 2
292 self.print_center(
293 Panel(
294 Align.center(
295 Text(ascii_logo, style=self.colors["logo"]) +
296 Text("\n" + " ".ljust(padding) + subtitle.lower(), style=f"{self.colors['accented']}")
297 ),
298 border_style=f"dim {self.colors["subtle"]}",
299 width=self.panel_width,
300 box=HEAVY_HEAD,
301 subtitle=f"[not dim {self.colors['subtle']}]{self.VERSION}[/]",
302 subtitle_align="right",
303 )
304 )
306 def select_files(self, file_data: list[dict]) -> ActionResult[list[int]]:
307 """Display file selector and return indices of selected files.
309 Args:
310 file_data: List of dicts with 'name' and 'size' keys
312 Returns:
313 List of selected file indices
314 """
315 selected_indices = []
316 current_index = 0
317 hints = f"[{self.colors['secondary']}]⬆︎ /⬇︎[/] :navigate [{self.colors['secondary']}][SPACE][/]:select [{self.colors['secondary']}][A][/]:all [{self.colors['secondary']}][ENTER][/]:confirm [{self.colors['secondary']}][BACKSPACE][/]:back [{self.colors['secondary']}][Q][/]:quit"
319 while True:
320 self.clear_and_show_header()
321 table = self._create_selection_table()
323 for i, file_info in enumerate(file_data):
324 checkbox = "✔" if i in selected_indices else "❏"
325 marker = f"[{self.colors['secondary']}]►[/]" if i == current_index else " "
327 if i == current_index:
328 checkbox_colored = f"[{self.colors['secondary']}]{checkbox}[/]"
329 filename_text = f"[{self.colors['secondary']}]{file_info['name']}[/]"
330 size_text = f"[{self.colors['secondary']}]({file_info['size']})[/]"
331 else:
332 checkbox_colored = checkbox
333 filename_text = f"[{self.colors['primary']}]{file_info['name']}[/]"
334 size_text = f"[{self.colors['subtle']}]({file_info['size']})[/]"
335 table.add_row(f"{marker} {checkbox_colored} {filename_text} {size_text}")
337 self.print_center(self._create_panel(table, title="select files for conversion", padding=(1, 0, 1, 0)))
338 self.print_center(self._create_hint_panel(hints))
340 token = self.keyboard_reader()
342 if token.key == KeyboardKey.UP:
343 current_index = (current_index - 1) % len(file_data)
344 elif token.key == KeyboardKey.DOWN:
345 current_index = (current_index + 1) % len(file_data)
346 elif token.key == KeyboardKey.SPACE:
347 if current_index in selected_indices:
348 selected_indices.remove(current_index)
349 else:
350 selected_indices.append(current_index)
351 elif token.key == KeyboardKey.ENTER:
352 break
353 elif token.key == KeyboardKey.BACKSPACE:
354 return ActionResult.back()
355 elif token.key == KeyboardKey.CHAR and token.char == "a":
356 if len(selected_indices) == len(file_data):
357 selected_indices = []
358 else:
359 selected_indices = list(range(len(file_data)))
360 elif token.key == KeyboardKey.CHAR and token.char == "q":
361 return ActionResult.terminate()
363 self.clear_and_show_header()
365 return ActionResult.value(selected_indices)
367 def get_path_input(self) -> ActionResult[str]:
368 """Get path input from user."""
369 self.clear_and_show_header()
371 result = self.input_center(title="select input source", hint="e.g. source.pdf or /data")
372 if result.strip().lower() == "\\q":
373 return ActionResult.terminate()
374 return ActionResult.value(result)
376 def select_output_format(self) -> ActionResult[OutputFormat]:
377 """Interactive output format selection menu."""
378 return self._radio_select(
379 [OutputFormat.PLAIN_TEXT, OutputFormat.MARKDOWN, OutputFormat.JSON],
380 title="select output format"
381 )
383 def select_merge_mode(self) -> ActionResult[MergeMode]:
384 """Interactive merge mode selection menu."""
385 return self._radio_select(
386 [MergeMode.NO_MERGE, MergeMode.MERGE, MergeMode.PER_PAGE],
387 title="select merge mode"
388 )
390 def prompt_merged_filename(self) -> ActionResult[str]:
391 """Prompt user for the name of the merged output file."""
392 self.clear_and_show_header()
394 result = self.input_center(title="select merged output", hint="output file name without extension")
395 if result.strip().lower() == "\\q":
396 return ActionResult.terminate()
397 return ActionResult.value(result.strip())
399 def get_progress_bar(self):
400 self.clear_and_show_header()
401 @contextmanager
402 def _progress_ctx():
403 progress = Progress(
404 SpinnerColumn(
405 speed=.75,
406 style=self.colors["accented"]
407 ),
408 StyledDescriptionColumn(self.colors),
409 BarColumn(
410 bar_width=None,
411 style=self.colors["subtle"],
412 complete_style=self.colors["accented"],
413 finished_style=self.colors["subtle"],
414 ),
415 StyledPercentageColumn(self.colors),
416 StyledTimeElapsedColumn(self.colors["accented"]),
417 console=self.console,
418 transient=True,
419 )
421 panel = self._create_panel(progress, title="selected files", padding=(1, 0, 1, 0))
422 term_width = self.console.size.width
423 centered = Align.center(panel, width=term_width)
424 with Live(centered, console=self.console, refresh_per_second=10):
425 yield progress
427 return _progress_ctx()
429 def show_error(self, message: str):
430 markup = f"[{self.colors['error']}]" + message + "[/]"
431 term_width = self.console.size.width
432 left_padding = (term_width - self.panel_width) // 2 + 1
433 self.console.print(" " * left_padding + markup, markup=True)
435 def show_conversion_summary(
436 self,
437 total_files: int,
438 output_count: int,
439 merge_mode: MergeMode,
440 merged_filename: Optional[str],
441 total_runtime: float,
442 total_input_size_formatted: str,
443 total_output_size_formatted: str,
444 single_output_filename: Optional[str] = None
445 ):
446 """Display comprehensive conversion summary and completion message."""
447 runtime_str = f"{total_runtime:.2f}s"
449 # Determine output description based on merge mode
450 if merge_mode == MergeMode.MERGE:
451 output_desc = f"1 merged file"
452 if merged_filename:
453 output_desc += f" ({merged_filename})"
454 elif merge_mode == MergeMode.PER_PAGE:
455 output_desc = f"{output_count} pages/chapters"
456 else: # no_merge
457 output_desc = single_output_filename if single_output_filename else f"{output_count} files"
459 content = (
460 f"[{self.colors['primary']}]files processed:{'':<4}[/] [{self.colors['secondary']}]{total_files}[/]\n"
461 f"[{self.colors['primary']}]output created:{'':<5}[/] [{self.colors['secondary']}]{output_desc}[/]\n"
462 f"[{self.colors['primary']}]input size:{'':<9}[/] [{self.colors['secondary']}]{total_input_size_formatted}[/]\n"
463 f"[{self.colors['primary']}]output size:{'':<8}[/] [{self.colors['secondary']}]{total_output_size_formatted}[/]\n\n"
464 f"[{self.colors['accented']}]total runtime:{'':<6} {runtime_str}[/]"
465 )
467 self.print_center(self._create_panel(
468 Text.from_markup(content),
469 title="conversion complete",
470 padding=(1, 0, 1, 1),
471 ))
473 def ask_again(self) -> ActionResult[bool]:
474 hints = f"[{self.colors['secondary']}][ENTER][/]:try again [{self.colors['secondary']}][Q][/]:quit"
475 self.print_center(self._create_hint_panel(hints))
476 while True:
477 token = self.keyboard_reader()
478 if token.key == KeyboardKey.ENTER:
479 return ActionResult.proceed()
480 elif token.key == KeyboardKey.CHAR and token.char == "q":
481 return ActionResult.terminate()
482 # Else continue waiting