dsap-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.
dsap/ui.py ADDED
@@ -0,0 +1,445 @@
1
+ """Rich Terminal UI Components for DSAP.
2
+
3
+ Provides beautiful terminal output using Rich library:
4
+ - Problem display with clickable links (OSC 8 hyperlinks)
5
+ - Statistics tables and progress bars
6
+ - Interactive quality rating prompts
7
+ - Session status displays
8
+ """
9
+
10
+ import webbrowser
11
+
12
+ from rich import box
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+ from rich.style import Style
17
+ from rich.table import Table
18
+
19
+ from dsap.models import Difficulty, Problem, ProblemProgress, Statistics
20
+
21
+
22
+ # Global console instance
23
+ console = Console()
24
+
25
+ # Difficulty color styles
26
+ DIFFICULTY_STYLES: dict[Difficulty, Style] = {
27
+ Difficulty.EASY: Style(color="green", bold=True),
28
+ Difficulty.MEDIUM: Style(color="yellow", bold=True),
29
+ Difficulty.HARD: Style(color="red", bold=True),
30
+ }
31
+
32
+
33
+ def make_link(url: str, text: str) -> str:
34
+ """Create a Rich markup hyperlink.
35
+
36
+ Rich automatically handles OSC 8 hyperlinks in supported terminals
37
+ (iTerm2, Windows Terminal, VSCode terminal, etc.)
38
+
39
+ Args:
40
+ url: The URL to link to
41
+ text: The display text
42
+
43
+ Returns:
44
+ Rich markup string with link
45
+ """
46
+ return f"[link={url}]{text}[/link]"
47
+
48
+
49
+ def display_welcome() -> None:
50
+ """Display welcome message when dsap is run without arguments."""
51
+ welcome = """
52
+ [bold cyan]DSAP[/bold cyan] - DSA Practice with Spaced Repetition
53
+
54
+ [bold]Quick Start:[/bold]
55
+ dsap load blind75 Load the Blind 75 problem set
56
+ dsap review Start reviewing due problems
57
+ dsap stats Check your progress
58
+
59
+ [bold]Commands:[/bold]
60
+ dsap --help See all available commands
61
+ dsap <cmd> --help Get help for a specific command
62
+
63
+ [dim]Master algorithms with scientifically-proven spaced repetition (SM-2)[/dim]
64
+ """
65
+ console.print(Panel(welcome.strip(), border_style="blue", box=box.ROUNDED))
66
+
67
+
68
+ def display_problem(
69
+ problem: Problem,
70
+ progress: ProblemProgress | None = None,
71
+ index: int | None = None,
72
+ total: int | None = None,
73
+ show_hints: bool = False,
74
+ ) -> None:
75
+ """Display a problem in a beautiful panel with clickable link.
76
+
77
+ Args:
78
+ problem: The problem to display
79
+ progress: Optional progress data
80
+ index: Current problem index (for "Problem 1/5" display)
81
+ total: Total problems in session
82
+ show_hints: Whether to show hints
83
+ """
84
+ lines: list[str] = []
85
+
86
+ # Title as clickable link
87
+ title_link = make_link(str(problem.url), problem.title)
88
+ lines.append(f"[bold cyan]{title_link}[/bold cyan]")
89
+ lines.append("")
90
+
91
+ # URL (also clickable, shown separately for clarity)
92
+ lines.append(f"[dim]{make_link(str(problem.url), str(problem.url))}[/dim]")
93
+ lines.append("")
94
+
95
+ # Metadata row
96
+ diff_style = DIFFICULTY_STYLES.get(problem.difficulty, Style())
97
+ lines.append(
98
+ f"[bold]Difficulty:[/bold] [{diff_style}]{problem.difficulty.value}[/] "
99
+ f"[bold]Category:[/bold] {problem.category}"
100
+ )
101
+
102
+ # Problem set info
103
+ if problem.problem_set != "custom":
104
+ lines.append(
105
+ f"[bold]Set:[/bold] {problem.problem_set} #{problem.problem_number}"
106
+ )
107
+
108
+ # Tags
109
+ if problem.tags:
110
+ tags_str = " ".join(f"[dim]#{tag}[/dim]" for tag in problem.tags[:6])
111
+ lines.append(f"[bold]Tags:[/bold] {tags_str}")
112
+
113
+ # Company tags
114
+ if problem.company_tags:
115
+ companies = ", ".join(problem.company_tags[:5])
116
+ if len(problem.company_tags) > 5:
117
+ companies += f" +{len(problem.company_tags) - 5} more"
118
+ lines.append(f"[bold]Companies:[/bold] {companies}")
119
+
120
+ # Description
121
+ if problem.description:
122
+ lines.append("")
123
+ lines.append(f"[italic]{problem.description}[/italic]")
124
+
125
+ # Progress info
126
+ if progress:
127
+ lines.append("")
128
+ lines.append("[bold]Your Progress:[/bold]")
129
+ status = (
130
+ "[green]Solved[/green]"
131
+ if progress.solved
132
+ else "[yellow]In Progress[/yellow]"
133
+ )
134
+ lines.append(f" Status: {status} | Attempts: {progress.attempts}")
135
+ lines.append(
136
+ f" EF: {progress.easiness_factor:.2f} | Interval: {progress.interval} days"
137
+ )
138
+ if progress.next_review:
139
+ lines.append(f" Next review: {progress.next_review.strftime('%Y-%m-%d')}")
140
+
141
+ # Hints (optional)
142
+ if show_hints and problem.hints:
143
+ lines.append("")
144
+ lines.append("[bold]Hints:[/bold]")
145
+ for i, hint in enumerate(problem.hints, 1):
146
+ lines.append(f" {i}. [dim]{hint}[/dim]")
147
+
148
+ # Build panel title
149
+ if index is not None and total is not None:
150
+ title = f"Problem {index}/{total}"
151
+ else:
152
+ title = "Problem"
153
+
154
+ panel = Panel(
155
+ "\n".join(lines),
156
+ title=f"[bold white]{title}[/bold white]",
157
+ border_style="blue",
158
+ box=box.ROUNDED,
159
+ )
160
+
161
+ console.print(panel)
162
+
163
+
164
+ def prompt_open_browser(url: str, auto_open: bool = True) -> bool:
165
+ """Prompt user to open problem in browser.
166
+
167
+ Args:
168
+ url: URL to open
169
+ auto_open: If True, ask to open; if False, just inform
170
+
171
+ Returns:
172
+ True if browser was opened
173
+ """
174
+ console.print()
175
+
176
+ if auto_open:
177
+ open_it = Confirm.ask(
178
+ "[bold]Open in browser?[/bold]",
179
+ default=True,
180
+ )
181
+ if open_it:
182
+ webbrowser.open(str(url))
183
+ console.print(
184
+ "[dim]Opened in browser. Solve the problem, then return here.[/dim]"
185
+ )
186
+ return True
187
+ else:
188
+ console.print("[dim]Click the link above to open the problem.[/dim]")
189
+
190
+ return False
191
+
192
+
193
+ def prompt_quality_rating() -> int | None:
194
+ """Prompt user for quality rating after reviewing a problem.
195
+
196
+ Returns:
197
+ Quality rating (0-5) or None if user wants to quit
198
+ """
199
+ console.print()
200
+ console.print("[bold]How well did you recall this problem?[/bold]")
201
+ console.print()
202
+
203
+ # Display rating options with colors
204
+ ratings = [
205
+ ("5", "green", "Perfect - instant recall, no hesitation"),
206
+ ("4", "green", "Good - correct, but had to think"),
207
+ ("3", "yellow", "Hard - struggled but eventually solved"),
208
+ ("2", "red", "Forgot - wrong, but solution was familiar"),
209
+ ("1", "red", "Blackout - wrong, barely recognized solution"),
210
+ ("0", "red", "Total blackout - no memory at all"),
211
+ ]
212
+
213
+ for num, color, desc in ratings:
214
+ console.print(f" [{color}]{num}[/{color}] - {desc}")
215
+
216
+ console.print(" [dim]q[/dim] - Quit session")
217
+ console.print()
218
+
219
+ while True:
220
+ try:
221
+ response = console.input("[bold]Rating (0-5 or q): [/bold]").strip().lower()
222
+
223
+ if response == "q":
224
+ return None
225
+
226
+ quality = int(response)
227
+ if 0 <= quality <= 5:
228
+ return quality
229
+
230
+ console.print("[red]Please enter a number between 0 and 5[/red]")
231
+ except ValueError:
232
+ console.print("[red]Invalid input. Enter 0-5 or 'q'[/red]")
233
+
234
+
235
+ def display_review_feedback(quality: int, next_interval: int) -> None:
236
+ """Display feedback after a review.
237
+
238
+ Args:
239
+ quality: The quality rating given
240
+ next_interval: Days until next review
241
+ """
242
+ console.print()
243
+
244
+ if quality >= 4:
245
+ emoji = "[green]"
246
+ message = "Excellent!"
247
+ elif quality == 3:
248
+ emoji = "[yellow]"
249
+ message = "Good effort!"
250
+ else:
251
+ emoji = "[red]"
252
+ message = "Keep practicing!"
253
+
254
+ interval_text = "tomorrow" if next_interval == 1 else f"in {next_interval} days"
255
+
256
+ console.print(f"{emoji}{message}[/] Next review: {interval_text}")
257
+ console.print()
258
+
259
+
260
+ def display_stats(stats: Statistics) -> None:
261
+ """Display comprehensive statistics in a beautiful format."""
262
+ # Main stats panel
263
+ main_lines = [
264
+ f"[bold]Total Problems:[/bold] {stats.total_problems}",
265
+ f"[bold]Solved:[/bold] [green]{stats.solved_problems}[/green] ({stats.solved_percentage}%)",
266
+ f"[bold]Reviewed:[/bold] {stats.reviewed_problems}",
267
+ "",
268
+ f"[bold]Due Today:[/bold] [yellow]{stats.due_today}[/yellow]",
269
+ f"[bold]Due This Week:[/bold] {stats.due_this_week}",
270
+ "",
271
+ f"[bold]Current Streak:[/bold] [cyan]{stats.current_streak}[/cyan] days",
272
+ f"[bold]Best Streak:[/bold] {stats.best_streak} days",
273
+ "",
274
+ f"[bold]Average EF:[/bold] {stats.average_easiness_factor}",
275
+ f"[bold]Total Reviews:[/bold] {stats.total_reviews}",
276
+ ]
277
+
278
+ console.print(
279
+ Panel(
280
+ "\n".join(main_lines),
281
+ title="[bold white]Statistics[/bold white]",
282
+ border_style="green",
283
+ box=box.ROUNDED,
284
+ )
285
+ )
286
+
287
+ # Difficulty breakdown table
288
+ table = Table(title="Difficulty Breakdown", box=box.SIMPLE)
289
+ table.add_column("Difficulty", style="bold")
290
+ table.add_column("Solved", justify="right", style="green")
291
+ table.add_column("Total", justify="right")
292
+ table.add_column("Progress", justify="left")
293
+
294
+ difficulties = [
295
+ ("Easy", stats.easy_solved, stats.easy_total, stats.easy_percentage, "green"),
296
+ (
297
+ "Medium",
298
+ stats.medium_solved,
299
+ stats.medium_total,
300
+ stats.medium_percentage,
301
+ "yellow",
302
+ ),
303
+ ("Hard", stats.hard_solved, stats.hard_total, stats.hard_percentage, "red"),
304
+ ]
305
+
306
+ for name, solved, total, pct, color in difficulties:
307
+ # Create mini progress bar
308
+ filled = int(pct / 10)
309
+ bar = f"[{color}]{'█' * filled}[/{color}][dim]{'░' * (10 - filled)}[/dim]"
310
+ table.add_row(
311
+ f"[{color}]{name}[/{color}]",
312
+ str(solved),
313
+ str(total),
314
+ f"{bar} {pct:.0f}%",
315
+ )
316
+
317
+ console.print()
318
+ console.print(table)
319
+
320
+
321
+ def display_problem_list(
322
+ problems: list[tuple[Problem, ProblemProgress | None]],
323
+ show_url: bool = False,
324
+ ) -> None:
325
+ """Display a list of problems in a table.
326
+
327
+ Args:
328
+ problems: List of (Problem, ProblemProgress or None) tuples
329
+ show_url: Whether to include URL column
330
+ """
331
+ if not problems:
332
+ console.print("[yellow]No problems found.[/yellow]")
333
+ return
334
+
335
+ table = Table(title="Problems", box=box.SIMPLE)
336
+ table.add_column("#", style="dim", width=4)
337
+ table.add_column("Title", style="bold", max_width=40)
338
+ table.add_column("Diff", justify="center", width=8)
339
+ table.add_column("Category", max_width=25)
340
+ table.add_column("Status", justify="center", width=10)
341
+ table.add_column("EF", justify="right", width=5)
342
+
343
+ if show_url:
344
+ table.add_column("URL", max_width=30)
345
+
346
+ for problem, progress in problems:
347
+ diff_style = DIFFICULTY_STYLES.get(problem.difficulty, Style())
348
+
349
+ if progress:
350
+ if progress.solved:
351
+ status = "[green]Solved[/green]"
352
+ else:
353
+ status = f"[yellow]{progress.attempts}x[/yellow]"
354
+ ef = f"{progress.easiness_factor:.1f}"
355
+ else:
356
+ status = "[dim]New[/dim]"
357
+ ef = "-"
358
+
359
+ # Make title clickable
360
+ title_link = make_link(str(problem.url), problem.title[:38])
361
+
362
+ row = [
363
+ str(problem.id or "-"),
364
+ title_link,
365
+ f"[{diff_style}]{problem.difficulty.value}[/]",
366
+ problem.category[:23],
367
+ status,
368
+ ef,
369
+ ]
370
+
371
+ if show_url:
372
+ row.append(str(problem.url)[:28])
373
+
374
+ table.add_row(*row)
375
+
376
+ console.print(table)
377
+
378
+
379
+ def display_session_summary(
380
+ reviewed: int,
381
+ total_due: int,
382
+ qualities: list[int],
383
+ ) -> None:
384
+ """Display summary at end of review session.
385
+
386
+ Args:
387
+ reviewed: Number of problems reviewed
388
+ total_due: Total that were due
389
+ qualities: List of quality ratings given
390
+ """
391
+ console.print()
392
+
393
+ if not qualities:
394
+ console.print("[yellow]No problems reviewed.[/yellow]")
395
+ return
396
+
397
+ avg_quality = sum(qualities) / len(qualities)
398
+
399
+ summary = [
400
+ "[bold]Session Complete![/bold]",
401
+ "",
402
+ f"Problems reviewed: [cyan]{reviewed}[/cyan]/{total_due}",
403
+ f"Average quality: [cyan]{avg_quality:.1f}[/cyan]/5",
404
+ ]
405
+
406
+ # Add encouragement based on performance
407
+ if avg_quality >= 4:
408
+ summary.append("")
409
+ summary.append("[green]Excellent session! Keep up the great work![/green]")
410
+ elif avg_quality >= 3:
411
+ summary.append("")
412
+ summary.append("[yellow]Good progress! Keep practicing.[/yellow]")
413
+ else:
414
+ summary.append("")
415
+ summary.append(
416
+ "[yellow]Some tough ones today. You'll get them next time![/yellow]"
417
+ )
418
+
419
+ console.print(
420
+ Panel(
421
+ "\n".join(summary),
422
+ border_style="cyan",
423
+ box=box.ROUNDED,
424
+ )
425
+ )
426
+
427
+
428
+ def display_success(message: str) -> None:
429
+ """Display a success message."""
430
+ console.print(f"[green]{message}[/green]")
431
+
432
+
433
+ def display_error(message: str) -> None:
434
+ """Display an error message."""
435
+ console.print(f"[red]Error: {message}[/red]")
436
+
437
+
438
+ def display_warning(message: str) -> None:
439
+ """Display a warning message."""
440
+ console.print(f"[yellow]Warning: {message}[/yellow]")
441
+
442
+
443
+ def display_info(message: str) -> None:
444
+ """Display an info message."""
445
+ console.print(f"[dim]{message}[/dim]")