uw-course 1.0.2__py3-none-any.whl → 2.0.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.
uw_course/ui/app.py ADDED
@@ -0,0 +1,730 @@
1
+ import re
2
+
3
+ from textual import on
4
+ from textual import events
5
+ from textual.app import App, ComposeResult
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.coordinate import Coordinate
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import (
10
+ Button,
11
+ Checkbox,
12
+ DataTable,
13
+ Footer,
14
+ Header,
15
+ Input,
16
+ Select,
17
+ Static,
18
+ )
19
+
20
+ from uw_course.ui.components import (
21
+ detail_form_widgets,
22
+ schedule_tabs_with_options,
23
+ )
24
+ from uw_course.ui.constants import (
25
+ ACTION_DETAIL,
26
+ ACTION_QUIT,
27
+ ACTION_SCHEDULE,
28
+ DETAIL_PLACEHOLDER,
29
+ SCHEDULE_PLACEHOLDER,
30
+ SIDEBAR_TITLE_DETAIL,
31
+ SIDEBAR_TITLE_SCHEDULE,
32
+ )
33
+ from uw_course.ui.schedule_view import render_weekly_schedule
34
+ from uw_course.pdfschedule import generate_pdf
35
+
36
+ from uw_course.ClassSchedule.runner import SearchAvalibleInTerm, get_course_detail, makeSchedule
37
+ from uw_course.DB.dbClass import dbClass
38
+ from uw_course.setting import Setting
39
+
40
+
41
+ def _label_collection(name):
42
+ match = re.match(r"^Class(\d{4})([A-Za-z]+)$", name)
43
+ if match:
44
+ year, term = match.groups()
45
+ return f"{year} {term}"
46
+ return name
47
+
48
+
49
+ def _vertical_label(label):
50
+ cleaned = re.sub(r"\\s+", "", label)
51
+ return "\\n".join(cleaned) if cleaned else label
52
+
53
+
54
+ def _parse_collection_file(path, db):
55
+ course_wish_list = []
56
+ with open(path, "r") as handle:
57
+ collection = handle.readline().strip().split("#")[0].strip()
58
+ next(handle, None)
59
+ for line in handle:
60
+ if line.startswith("#") or not line.strip():
61
+ continue
62
+ info = line.strip().split(",")
63
+ course = info[0].strip()
64
+ if len(info) > 1 and info[1].strip():
65
+ course_wish_list.append(
66
+ SearchAvalibleInTerm(db, course, int(info[1].strip()), quiet=True)
67
+ )
68
+ else:
69
+ course_wish_list.append(SearchAvalibleInTerm(db, course, quiet=True))
70
+ return collection, [c for c in course_wish_list if c is not None]
71
+
72
+
73
+
74
+
75
+ class CourseApp(App):
76
+ BINDINGS = [
77
+ ("j", "focus_next", "Next"),
78
+ ("k", "focus_previous", "Previous"),
79
+ ("h", "focus_left", "Left"),
80
+ ("l", "focus_right", "Right"),
81
+ ("enter", "edit_cell", "Edit Cell"),
82
+ ("q", "app.quit", "Quit"),
83
+ ]
84
+
85
+ CSS = """
86
+ Screen {
87
+ background: #1a1b26;
88
+ color: #c0caf5;
89
+ }
90
+
91
+ #app-body { padding: 1 2; }
92
+
93
+ #layout {
94
+ height: 1fr;
95
+ }
96
+
97
+ #main {
98
+ width: 3fr;
99
+ height: 1fr;
100
+ }
101
+
102
+ #sidebar {
103
+ width: 2fr;
104
+ height: 1fr;
105
+ margin: 0 0 0 2;
106
+ padding: 1;
107
+ background: $panel;
108
+ border: round $secondary;
109
+ }
110
+
111
+ #title {
112
+ margin: 1 0 0 0;
113
+ color: #7aa2f7;
114
+ text-style: bold;
115
+ }
116
+
117
+ #subtitle {
118
+ margin: 0 0 1 0;
119
+ color: #9aa5ce;
120
+ }
121
+
122
+ #sidebar-title {
123
+ color: #7aa2f7;
124
+ text-style: bold;
125
+ margin: 0 0 1 0;
126
+ }
127
+
128
+ #actions {
129
+ height: auto;
130
+ margin: 0 0 1 0;
131
+ padding: 1;
132
+ background: #1f2335;
133
+ border: round #3b4261;
134
+ }
135
+
136
+ #status {
137
+ height: auto;
138
+ color: #9aa5ce;
139
+ margin: 0 0 1 0;
140
+ }
141
+
142
+ #form {
143
+ height: 1fr;
144
+ overflow-y: auto;
145
+ padding: 1;
146
+ background: #24283b;
147
+ border: round #3b4261;
148
+ }
149
+
150
+ #output {
151
+ height: 1fr;
152
+ border: round #7aa2f7;
153
+ padding: 1;
154
+ margin: 1 0 0 0;
155
+ background: #1a1b26;
156
+ }
157
+
158
+ #schedule-tabs { height: auto; }
159
+ #manual-pane, #load-pane { height: auto; }
160
+
161
+ #actions Button {
162
+ margin: 0 1 0 0;
163
+ width: 1fr;
164
+ min-width: 18;
165
+ height: 3;
166
+ padding: 0 2;
167
+ text-style: bold;
168
+ border: round #3b4261;
169
+ background: #1f2335;
170
+ color: #c0caf5;
171
+ }
172
+
173
+ #actions Button:hover {
174
+ background: #24283b;
175
+ border: round #7aa2f7;
176
+ }
177
+
178
+ #actions Button:focus {
179
+ outline: none;
180
+ border: round #7aa2f7;
181
+ }
182
+
183
+ #action-schedule {
184
+ background: #7aa2f7;
185
+ color: #1a1b26;
186
+ border: round #7aa2f7;
187
+ }
188
+
189
+ #action-schedule:hover {
190
+ background: #89b4fa;
191
+ }
192
+
193
+ #action-detail {
194
+ background: #1f2335;
195
+ color: #c0caf5;
196
+ border: round #3b4261;
197
+ }
198
+
199
+ #action-detail:hover {
200
+ background: #24283b;
201
+ }
202
+
203
+ #action-quit {
204
+ background: #f7768e;
205
+ color: #1a1b26;
206
+ border: round #f7768e;
207
+ }
208
+
209
+ #action-quit:hover {
210
+ background: #ff7a93;
211
+ }
212
+
213
+ #schedule-buttons {
214
+ height: auto;
215
+ margin: 1 0 0 0;
216
+ }
217
+
218
+ #schedule-buttons Button {
219
+ width: auto;
220
+ min-width: 16;
221
+ }
222
+
223
+ #schedule-edit-buttons {
224
+ height: auto;
225
+ margin: 1 0;
226
+ }
227
+
228
+ #schedule-edit-buttons Button {
229
+ width: auto;
230
+ min-width: 16;
231
+ }
232
+
233
+ .cell-editor-screen {
234
+ background: #1a1b26;
235
+ }
236
+
237
+ #cell-editor {
238
+ height: 1fr;
239
+ padding: 2;
240
+ }
241
+
242
+ #cell-editor-title {
243
+ color: #7aa2f7;
244
+ text-style: bold;
245
+ margin: 0 0 1 0;
246
+ }
247
+
248
+ #cell-editor-input {
249
+ height: auto;
250
+ border: round #3b4261;
251
+ background: #1f2335;
252
+ color: #c0caf5;
253
+ padding: 0 1;
254
+ text-style: bold;
255
+ }
256
+
257
+ #cell-editor-buttons {
258
+ margin: 1 0 0 0;
259
+ }
260
+
261
+ #cell-editor-buttons Button {
262
+ width: 1fr;
263
+ min-width: 16;
264
+ }
265
+
266
+ #manual-actions {
267
+ height: auto;
268
+ margin: 1 0 0 0;
269
+ }
270
+
271
+ #manual-actions Button {
272
+ width: auto;
273
+ min-width: 16;
274
+ }
275
+
276
+ #manual-actions Checkbox {
277
+ margin: 0 1;
278
+ }
279
+
280
+ #manual-options {
281
+ height: auto;
282
+ margin: 1 0 0 0;
283
+ }
284
+
285
+ #manual-options Button {
286
+ width: auto;
287
+ min-width: 16;
288
+ }
289
+
290
+ #manual-options Checkbox {
291
+ margin: 0 1 0 0;
292
+ }
293
+
294
+ #load-pane {
295
+ height: auto;
296
+ }
297
+
298
+ #load-options {
299
+ height: auto;
300
+ margin: 1 0 0 0;
301
+ }
302
+
303
+ #load-options Button {
304
+ width: auto;
305
+ min-width: 16;
306
+ }
307
+
308
+ #load-options Checkbox {
309
+ margin: 0 1 0 0;
310
+ }
311
+
312
+ Button.-primary {
313
+ background: #7aa2f7;
314
+ color: #1a1b26;
315
+ text-style: bold;
316
+ }
317
+
318
+ Button.-error {
319
+ background: #f7768e;
320
+ color: #1a1b26;
321
+ text-style: bold;
322
+ }
323
+
324
+ Input, Select {
325
+ margin: 0 0 1 0;
326
+ }
327
+
328
+ DataTable {
329
+ margin: 1 0;
330
+ height: 6;
331
+ border: round #3b4261;
332
+ }
333
+ """
334
+
335
+ def __init__(self):
336
+ super().__init__()
337
+ self.db = dbClass()
338
+ self.setting = Setting()
339
+ self.schedule_entries = []
340
+ self.output_placeholder = SCHEDULE_PLACEHOLDER
341
+ self._editing_cell = None
342
+
343
+ def on_mount(self) -> None:
344
+ self._set_sidebar(SIDEBAR_TITLE_SCHEDULE, SCHEDULE_PLACEHOLDER)
345
+ self._mount_schedule_form()
346
+
347
+ def compose(self) -> ComposeResult:
348
+ yield Header()
349
+ with Vertical(id="app-body"):
350
+ yield Static("UW Course Helper", id="title")
351
+ yield Static("Search courses, build schedules, export PDFs.", id="subtitle")
352
+ with Horizontal(id="actions"):
353
+ yield Button("Build Schedule", id="action-schedule", variant="primary")
354
+ yield Button("Check Course Detail", id="action-detail")
355
+ yield Button("Quit", id="action-quit", variant="error")
356
+ yield Static("Choose an action to begin.", id="status")
357
+ with Horizontal(id="layout"):
358
+ with Vertical(id="main"):
359
+ yield Vertical(id="form")
360
+ with Vertical(id="sidebar"):
361
+ yield Static(SIDEBAR_TITLE_SCHEDULE, id="sidebar-title")
362
+ output = Static(SCHEDULE_PLACEHOLDER, id="output")
363
+ output.display = True
364
+ yield output
365
+ yield Footer()
366
+
367
+ def _set_status(self, message):
368
+ self.query_one("#status", Static).update(message)
369
+
370
+ def _set_output(self, message):
371
+ output = self.query_one("#output", Static)
372
+ output.update(message or self.output_placeholder)
373
+ output.display = True
374
+
375
+ def _set_sidebar_title(self, title):
376
+ self.query_one("#sidebar-title", Static).update(title)
377
+
378
+ def _reset_form(self):
379
+ form = self.query_one("#form", Vertical)
380
+ form.remove_children()
381
+ for widget in self.query("#schedule-collection, #schedule-tabs, #schedule-term-hint"):
382
+ widget.remove()
383
+ self._set_output("")
384
+
385
+ def _set_sidebar(self, title, placeholder):
386
+ self.output_placeholder = placeholder
387
+ self._set_sidebar_title(title)
388
+ self._set_output("")
389
+
390
+ def _mount_detail_form(self):
391
+ if self.query("#detail-input"):
392
+ self.query_one("#detail-input", Input).focus()
393
+ return
394
+ form = self.query_one("#form", Vertical)
395
+ for widget in detail_form_widgets():
396
+ form.mount(widget)
397
+ self._set_status("Enter a course code to view description.")
398
+
399
+ def _mount_schedule_form(self):
400
+ if self.query("#schedule-collection"):
401
+ self.query_one("#schedule-collection", Select).focus()
402
+ return
403
+ form = self.query_one("#form", Vertical)
404
+ collections = self.db.listClassCollections()
405
+ if not collections:
406
+ self._set_status("No term collections found in the database.")
407
+ options = [(_label_collection(name), name) for name in collections]
408
+ form.mount(schedule_tabs_with_options(options))
409
+ if collections:
410
+ self._set_status("Add courses manually or load a config file, then generate the schedule.")
411
+ self._update_schedule_preview()
412
+
413
+ def _update_schedule_preview(self):
414
+ if not self.query("#schedule-table"):
415
+ return
416
+ table = self.query_one("#schedule-table", DataTable)
417
+ table.clear(columns=True)
418
+ table.show_row_labels = True
419
+ if not self.schedule_entries:
420
+ table.add_columns("No Courses")
421
+ table.add_row("", label="Course")
422
+ table.add_row("", label="Class ID")
423
+ return
424
+ table.show_header = False
425
+ table.add_columns(*[entry["course"] for entry in self.schedule_entries])
426
+ courses = [entry["course"] for entry in self.schedule_entries]
427
+ class_ids = [
428
+ "" if entry.get("class_id") is None else str(entry.get("class_id"))
429
+ for entry in self.schedule_entries
430
+ ]
431
+ table.add_row(*courses, label="Course")
432
+ table.add_row(*class_ids, label="Class ID")
433
+
434
+ def on_button_pressed(self, event):
435
+ button_id = event.button.id
436
+ if button_id is None or not button_id.startswith("action-"):
437
+ return
438
+ if button_id == ACTION_QUIT:
439
+ self.exit()
440
+ return
441
+
442
+ self._reset_form()
443
+
444
+ if button_id == ACTION_DETAIL:
445
+ self._set_sidebar(SIDEBAR_TITLE_DETAIL, DETAIL_PLACEHOLDER)
446
+ self._mount_detail_form()
447
+ elif button_id == ACTION_SCHEDULE:
448
+ self._set_sidebar(SIDEBAR_TITLE_SCHEDULE, SCHEDULE_PLACEHOLDER)
449
+ self._mount_schedule_form()
450
+
451
+ @on(Button.Pressed, "#detail-run")
452
+ def on_detail_run(self) -> None:
453
+ course = self.query_one("#detail-input", Input).value.strip()
454
+ if not course:
455
+ self._set_status("Please enter a course code.")
456
+ return
457
+ course_info = get_course_detail(self.db, course)
458
+ if not course_info:
459
+ self._set_output(f"Course not found: {course}")
460
+ return
461
+ detail = [
462
+ f"{course} — {course_info.get('courseDescription') or 'No description'}",
463
+ f"Credit: {course_info.get('courseCredit') or 'N/A'}",
464
+ ]
465
+ requirements = course_info.get("requirementsDescription")
466
+ if requirements:
467
+ detail.append(f"Requirements: {requirements}")
468
+ self._set_output("\n".join(detail))
469
+
470
+ @on(Button.Pressed, "#schedule-run")
471
+ def on_schedule_run(self) -> None:
472
+ gray = self.query_one("#schedule-gray", Checkbox).value
473
+ self._build_schedule(gray)
474
+
475
+ def _build_schedule(self, gray: bool, collection_override: str | None = None) -> None:
476
+ try:
477
+ if collection_override:
478
+ collection = collection_override
479
+ if self.query("#schedule-collection"):
480
+ self.query_one("#schedule-collection", Select).value = collection
481
+ else:
482
+ collection = self.query_one("#schedule-collection", Select).value
483
+ if not isinstance(collection, str) or not collection:
484
+ self._set_status("Please select a collection.")
485
+ return
486
+ self.db.switchCollection(collection)
487
+ course_wish_list = []
488
+ for entry in self.schedule_entries:
489
+ course = entry["course"]
490
+ class_id = entry.get("class_id")
491
+ if class_id is not None:
492
+ course_wish_list.append(SearchAvalibleInTerm(self.db, course, class_id, quiet=True))
493
+ else:
494
+ course_wish_list.append(SearchAvalibleInTerm(self.db, course, quiet=True))
495
+ course_wish_list = [c for c in course_wish_list if c is not None]
496
+ makeSchedule(self.db, courseWishList=course_wish_list, gray=gray)
497
+ self._set_output(render_weekly_schedule(self.setting.outDir))
498
+ self._set_status("Schedule generated.")
499
+ except Exception as exc:
500
+ self._set_output(f"Failed to build schedule: {exc}")
501
+
502
+ @on(Select.Changed, "#schedule-collection")
503
+ def on_schedule_collection_changed(self) -> None:
504
+ collection = self.query_one("#schedule-collection", Select).value
505
+ if not collection:
506
+ return
507
+ if self.query("#schedule-term-hint"):
508
+ hint = self.query_one("#schedule-term-hint", Static)
509
+ hint.remove()
510
+ self._update_schedule_preview()
511
+
512
+ @on(Button.Pressed, "#schedule-export")
513
+ def on_export_run(self) -> None:
514
+ try:
515
+ generate_pdf(self.setting.outDir)
516
+ self._set_output("PDF export completed.")
517
+ except Exception as exc:
518
+ self._set_output(f"Export failed: {exc}")
519
+
520
+ @on(Button.Pressed, "#schedule-add")
521
+ def on_schedule_add(self) -> None:
522
+ course = self.query_one("#schedule-course", Input).value.strip()
523
+ class_id_raw = self.query_one("#schedule-class-id", Input).value.strip()
524
+ if not course:
525
+ self._set_status("Please enter a course code.")
526
+ return
527
+ if class_id_raw:
528
+ try:
529
+ class_id = int(class_id_raw)
530
+ except ValueError:
531
+ self._set_status("Class ID must be a number.")
532
+ return
533
+ else:
534
+ class_id = None
535
+ self.schedule_entries.append({"course": course, "class_id": class_id})
536
+ self.query_one("#schedule-course", Input).value = ""
537
+ self.query_one("#schedule-class-id", Input).value = ""
538
+ self._update_schedule_preview()
539
+ self._set_status(f"Added {course} successfully.")
540
+
541
+ @on(Button.Pressed, "#schedule-clear")
542
+ def on_schedule_clear(self) -> None:
543
+ self.schedule_entries = []
544
+ self._update_schedule_preview()
545
+ self._set_status("Cleared schedule list.")
546
+
547
+ @on(Button.Pressed, "#schedule-remove")
548
+ def on_schedule_remove(self) -> None:
549
+ if not self.query("#schedule-table"):
550
+ return
551
+ if not self.schedule_entries:
552
+ self._set_status("No entries to remove.")
553
+ return
554
+ table = self.query_one("#schedule-table", DataTable)
555
+ col_index = table.cursor_column
556
+ if col_index is None:
557
+ self._set_status("Select a column to remove.")
558
+ return
559
+ if col_index >= len(self.schedule_entries):
560
+ self._set_status("Selected row is out of range. Refreshing list.")
561
+ self._update_schedule_preview()
562
+ return
563
+ self.schedule_entries.pop(col_index)
564
+ self._update_schedule_preview()
565
+ self._set_status("Removed selected entry.")
566
+
567
+ @on(DataTable.CellSelected, "#schedule-table")
568
+ def on_schedule_cell_selected(self, event: DataTable.CellSelected) -> None:
569
+ if not self.schedule_entries:
570
+ return
571
+ self._editing_cell = (event.coordinate.row, event.coordinate.column)
572
+ self._set_status("Press Enter to edit the selected cell.")
573
+
574
+ def on_key(self, event: events.Key) -> None:
575
+ if event.key not in ("enter", "e"):
576
+ return
577
+ if not self.query("#schedule-table"):
578
+ return
579
+ table = self.query_one("#schedule-table", DataTable)
580
+ if not table.has_focus:
581
+ return
582
+ self.action_edit_cell()
583
+ event.stop()
584
+
585
+ def action_edit_cell(self) -> None:
586
+ if not self.query("#schedule-table"):
587
+ return
588
+ table = self.query_one("#schedule-table", DataTable)
589
+ row_index = table.cursor_row
590
+ col_index = table.cursor_column
591
+ if row_index is None or col_index is None:
592
+ self._set_status("Select a cell to edit.")
593
+ return
594
+ if col_index >= len(self.schedule_entries):
595
+ self._set_status("Selected column is out of range. Refreshing list.")
596
+ self._update_schedule_preview()
597
+ return
598
+ self._editing_cell = (row_index, col_index)
599
+ self._show_cell_editor(row_index, col_index)
600
+
601
+ def _show_cell_editor(self, row_index, col_index):
602
+ if not self.query("#schedule-table"):
603
+ return
604
+ table = self.query_one("#schedule-table", DataTable)
605
+ cell_value = table.get_cell_at(Coordinate(row_index, col_index))
606
+ self._editing_cell = (row_index, col_index)
607
+ screen = _CellEditorScreen(
608
+ f"Edit cell ({row_index}, {col_index})",
609
+ "" if cell_value is None else str(cell_value),
610
+ )
611
+ self._set_status("Editing cell: press Enter to save and refresh.")
612
+ self.push_screen(screen, self._apply_cell_edit)
613
+
614
+ def _apply_cell_edit(self, value: str | None) -> None:
615
+ if value is None:
616
+ self._set_status("Edit canceled.")
617
+ return
618
+ if self._editing_cell is None:
619
+ return
620
+ row_index, col_index = self._editing_cell
621
+ if col_index >= len(self.schedule_entries):
622
+ self._set_status("Selected column is out of range. Refreshing list.")
623
+ self._update_schedule_preview()
624
+ return
625
+ new_value = value.strip()
626
+ if row_index == 0:
627
+ if not new_value:
628
+ self._set_status("Course code cannot be empty.")
629
+ return
630
+ self.schedule_entries[col_index]["course"] = new_value
631
+ else:
632
+ if new_value:
633
+ try:
634
+ self.schedule_entries[col_index]["class_id"] = int(new_value)
635
+ except ValueError:
636
+ self._set_status("Class ID must be a number.")
637
+ return
638
+ else:
639
+ self.schedule_entries[col_index]["class_id"] = None
640
+ self._update_schedule_preview()
641
+ self._set_status("Updated entry.")
642
+
643
+ @on(Button.Pressed, "#schedule-load")
644
+ def on_schedule_load(self) -> None:
645
+ if not self.query("#schedule-load-file"):
646
+ self._set_status("Switch to Load Config tab first.")
647
+ return
648
+ path = (
649
+ self.query_one("#schedule-load-file", Input).value.strip()
650
+ or f"{self.setting.dataName}/schema.txt"
651
+ )
652
+ if not path:
653
+ self._set_status("Please enter a config file path to load.")
654
+ return
655
+ self._set_status(f"Loading config from {path} ...")
656
+ self.refresh()
657
+ self.call_later(self._load_config, path)
658
+
659
+ def _load_config(self, path: str) -> None:
660
+ try:
661
+ self._set_status(f"Reading {path} ...")
662
+ collection, course_wish_list = _parse_collection_file(path, self.db)
663
+ self.schedule_entries = []
664
+ self.query_one("#schedule-collection", Select).value = collection
665
+ for entry in course_wish_list:
666
+ if len(entry) == 2:
667
+ self.schedule_entries.append({"course": entry[0], "class_id": entry[1]})
668
+ else:
669
+ self.schedule_entries.append({"course": entry[0], "class_id": None})
670
+ self._update_schedule_preview()
671
+ gray = self.query_one("#schedule-gray", Checkbox).value
672
+ self._build_schedule(gray, collection_override=collection)
673
+ self._set_status("Config loaded and schedule generated.")
674
+ except Exception as exc:
675
+ self._set_status(f"Failed to load config: {exc}")
676
+ self._set_output(f"Failed to load config: {exc}")
677
+
678
+ @on(Button.Pressed, "#schedule-save")
679
+ def on_schedule_save(self) -> None:
680
+ path = (
681
+ self.query_one("#schedule-save-file", Input).value.strip()
682
+ or f"{self.setting.dataName}/schema.txt"
683
+ )
684
+ collection = self.query_one("#schedule-collection", Select).value
685
+ if not collection:
686
+ self._set_status("Please enter a collection name before saving.")
687
+ return
688
+ try:
689
+ with open(path, "w") as handle:
690
+ handle.write(f"{collection}\n\n")
691
+ for entry in self.schedule_entries:
692
+ if entry.get("class_id") is not None:
693
+ handle.write(f"{entry['course']}, {entry['class_id']}\n")
694
+ else:
695
+ handle.write(f"{entry['course']}\n")
696
+ self._set_output(f"Config saved to: {path}")
697
+ except Exception as exc:
698
+ self._set_output(f"Failed to save config: {exc}")
699
+
700
+
701
+ class _CellEditorScreen(ModalScreen[str]):
702
+ def __init__(self, title, value):
703
+ super().__init__()
704
+ self._title = title
705
+ self._value = value
706
+
707
+ def on_mount(self) -> None:
708
+ self.add_class("cell-editor-screen")
709
+
710
+ def compose(self) -> ComposeResult:
711
+ with Vertical(id="cell-editor"):
712
+ yield Static(self._title, id="cell-editor-title")
713
+ yield Input(value=self._value, id="cell-editor-input")
714
+ with Horizontal(id="cell-editor-buttons"):
715
+ yield Button("Save", id="cell-editor-save", variant="primary")
716
+ yield Button("Cancel", id="cell-editor-cancel")
717
+
718
+ def on_button_pressed(self, event: Button.Pressed) -> None:
719
+ if event.button.id == "cell-editor-save":
720
+ value = self.query_one("#cell-editor-input", Input).value
721
+ self.dismiss(value)
722
+ elif event.button.id == "cell-editor-cancel":
723
+ self.dismiss(None)
724
+
725
+ def on_input_submitted(self, event: Input.Submitted) -> None:
726
+ self.dismiss(event.value)
727
+
728
+
729
+ def run_app():
730
+ CourseApp().run()