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

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 

25 

26HORIZONTALS_NO_BOTTOM = Box( 

27 " ── \n" 

28 " \n" 

29 " ── \n" 

30 " \n" 

31 " ── \n" 

32 " ── \n" 

33 " \n" 

34 " \n" 

35) 

36 

37# Breadcrumb is now a simple list of strings representing the path from the 

38# workflow state stack. Legacy `BreadcrumbState` dataclass was removed. 

39 

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 

46 

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) 

52 

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}" 

61 

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) 

65 

66 def render(self, task): 

67 fields = getattr(task, "fields", {}) or {} 

68 status = fields.get("status", "pending") 

69 

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) 

76 

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) 

83 

84 # Pending or no start time 

85 return Text("00:00", style=self._style) 

86 

87class StyledPercentageColumn(TextColumn): 

88 def __init__(self, colors: dict): 

89 super().__init__("{task.percentage:>3.0f}%") 

90 self.colors = colors 

91 

92 def render(self, task): 

93 fields = getattr(task, "fields", {}) or {} 

94 status = fields.get("status", "pending") 

95 percentage = f"{task.percentage:>3.0f}%" 

96 

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}[/]") 

101 

102class StyledDescriptionColumn(TextColumn): 

103 def __init__(self, colors: dict): 

104 super().__init__("[progress.description]{task.description}") 

105 self.colors = colors 

106 

107 def render(self, task): 

108 fields = getattr(task, "fields", {}) or {} 

109 status = fields.get("status", "pending") 

110 filename = fields.get("filename", "") 

111 

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}[/]") 

118 

119class RetroCLI(UIInterface): 

120 VERSION = "1.0.0" 

121 

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 {})} 

136 

137 # Breadcrumb state (updated by controller on state transitions) 

138 self.breadcrumb = [] 

139 

140 @property 

141 def keyboard_reader(self): 

142 return self._keyboard_reader 

143 

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) 

148 

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)) 

162 

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 ) 

171 

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 

181 

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}" 

193 

194 def _radio_select(self, options: list, title: str) -> ActionResult: 

195 """Generic radio-button selection menu. 

196  

197 Args: 

198 options: List of enum values with display_name and display_hint properties 

199 title: Panel title text 

200  

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" 

206 

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 )) 

216 

217 self.print_center(self._create_panel(table, title=title, padding=(1, 0, 1, 0))) 

218 self.print_center(self._create_hint_panel(hints)) 

219 

220 token = self.keyboard_reader() 

221 

222 if token.key == KeyboardKey.BACKSPACE: 

223 return ActionResult.back() 

224 

225 if token.key == KeyboardKey.CHAR and token.char == "q": 

226 return ActionResult.terminate() 

227 

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]) 

234 

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)) 

239 

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))) 

247 

248 hints = f"[{self.colors['secondary']}][ENTER][/]:confirm [{self.colors['secondary']}][\Q][/]:quit" 

249 self.print_center(self._create_hint_panel(hints)) 

250 

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 

259 

260 

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() 

266 

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}[/]")) 

275 

276 self.print_center(self._create_panel(breadcrumb_text, padding=(0, 0, 0, 0), box=MINIMAL)) 

277 

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 

291 

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 ) 

305 

306 def select_files(self, file_data: list[dict]) -> ActionResult[list[int]]: 

307 """Display file selector and return indices of selected files. 

308  

309 Args: 

310 file_data: List of dicts with 'name' and 'size' keys 

311  

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" 

318 

319 while True: 

320 self.clear_and_show_header() 

321 table = self._create_selection_table() 

322 

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 " " 

326 

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}") 

336 

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)) 

339 

340 token = self.keyboard_reader() 

341 

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() 

362 

363 self.clear_and_show_header() 

364 

365 return ActionResult.value(selected_indices) 

366 

367 def get_path_input(self) -> ActionResult[str]: 

368 """Get path input from user.""" 

369 self.clear_and_show_header() 

370 

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) 

375 

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 ) 

382 

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 ) 

389 

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() 

393 

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()) 

398 

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 ) 

420 

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 

426 

427 return _progress_ctx() 

428 

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) 

434 

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" 

448 

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" 

458 

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 ) 

466 

467 self.print_center(self._create_panel( 

468 Text.from_markup(content), 

469 title="conversion complete", 

470 padding=(1, 0, 1, 1), 

471 )) 

472 

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 

483