From 05838116d0687b650b491e151c2e377cfb5ebdff Mon Sep 17 00:00:00 2001 From: benya Date: Sat, 18 Apr 2026 23:22:04 +0300 Subject: [PATCH] Make GUI more EAC-like --- whipper/gui.py | 280 ++++++++++++++++++++++++++++++++------- whipper/test/test_gui.py | 14 +- 2 files changed, 248 insertions(+), 46 deletions(-) diff --git a/whipper/gui.py b/whipper/gui.py index 48b9fb9..a10d2de 100644 --- a/whipper/gui.py +++ b/whipper/gui.py @@ -129,6 +129,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): self.track_view = None self.log_buffer = None self.release_details = None + self.track_columns = None self.info_labels = {} self.status_label = None @@ -149,35 +150,121 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): window = Gtk.ApplicationWindow(application=self) window.set_title("Whipper") window.set_icon_name("com.github.whipper_team.Whipper") - window.set_default_size(1120, 760) - window.set_border_width(12) + window.set_default_size(1380, 900) + window.set_border_width(8) + self._install_css() - root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + root.get_style_context().add_class("eac-shell") window.add(root) - root.pack_start(self._build_controls(), False, False, 0) - root.pack_start(self._build_actions(), False, False, 0) + root.pack_start(self._build_transport_strip(), False, False, 0) root.pack_start(self._build_progress(), False, False, 0) root.pack_start(self._build_main_content(), True, True, 0) - root.pack_start(self._build_log(), True, True, 0) + root.pack_start(self._build_log(), False, True, 0) self._refresh_devices() self._load_gui_settings() window.show_all() return window + @staticmethod + def _install_css(): + provider = Gtk.CssProvider() + provider.load_from_data( + b""" + .eac-shell { + background: #d9d9d9; + } + frame > border { + border: 1px solid #8a8a8a; + border-radius: 0; + background: #ececec; + padding: 4px; + } + frame > label { + color: #1f1f1f; + font-weight: 700; + letter-spacing: 0.04em; + } + .eac-toolbar { + background: linear-gradient(to bottom, #f7f7f7, #d8d8d8); + border: 1px solid #8a8a8a; + padding: 6px; + } + .eac-toolbar button { + border-radius: 0; + padding: 4px 10px; + background: linear-gradient(to bottom, #fefefe, #dbdbdb); + border: 1px solid #7e7e7e; + color: #111111; + } + .eac-toolbar button.suggested-action { + background: linear-gradient(to bottom, #e5f2ff, #b7d4f3); + } + .eac-toolbar button.destructive-action { + background: linear-gradient(to bottom, #fbe7e7, #e7b7b7); + } + .eac-matrix, + .eac-log { + background: #fcfcfc; + color: #121212; + border: 1px solid #919191; + } + .eac-log text { + background: #fcfcfc; + color: #111111; + font: 10pt Monospace; + } + .eac-status { + font-weight: 700; + } + .eac-subtle { + color: #4c4c4c; + } + progressbar trough { + min-height: 16px; + border-radius: 0; + background: #cfcfcf; + border: 1px solid #8e8e8e; + } + progressbar progress { + border-radius: 0; + background: linear-gradient(to bottom, #8fc2f3, #4f84b6); + } + """ + ) + Gtk.StyleContext.add_provider_for_screen( + Gtk.Window().get_screen(), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def _build_transport_strip(self): + frame = Gtk.Frame(label="Extraction Control Center") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8) + frame.add(box) + box.pack_start(self._build_controls(), False, False, 0) + box.pack_start(self._build_actions(), False, False, 0) + return frame + def _build_controls(self): - grid = Gtk.Grid(column_spacing=10, row_spacing=10) + grid = Gtk.Grid(column_spacing=8, row_spacing=8) grid.attach(Gtk.Label(label="Drive", xalign=0), 0, 0, 1, 1) self.device_combo = Gtk.ComboBoxText() self.device_combo.connect("changed", self._on_device_changed) - grid.attach(self.device_combo, 1, 0, 2, 1) + grid.attach(self.device_combo, 1, 0, 1, 1) - self.refresh_button = Gtk.Button(label="Refresh Drives") + self.refresh_button = Gtk.Button(label="Detect Drives") self.refresh_button.connect("clicked", self._on_refresh_clicked) self.refresh_button.set_tooltip_text("Refresh the list of detected optical drives") - grid.attach(self.refresh_button, 3, 0, 1, 1) + grid.attach(self.refresh_button, 2, 0, 1, 1) + + grid.attach(Gtk.Label(label="Status", xalign=0), 3, 0, 1, 1) + self.status_label = Gtk.Label(label="Idle", xalign=0) + self.status_label.set_selectable(True) + grid.attach(self.status_label, 4, 0, 2, 1) grid.attach(Gtk.Label(label="Output", xalign=0), 0, 1, 1, 1) self.output_button = Gtk.FileChooserButton( @@ -186,13 +273,21 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): ) self.output_button.set_filename(os.path.expanduser("~")) self.output_button.connect("file-set", self._on_settings_changed) - grid.attach(self.output_button, 1, 1, 3, 1) + grid.attach(self.output_button, 1, 1, 2, 1) + + grid.attach(Gtk.Label(label="Logger", xalign=0), 3, 1, 1, 1) + self.logger_combo = Gtk.ComboBoxText() + for logger_name in sorted(result.getLoggers()): + self.logger_combo.append(logger_name, logger_name) + self.logger_combo.set_active_id("whipper") + self.logger_combo.connect("changed", self._on_settings_changed) + grid.attach(self.logger_combo, 4, 1, 2, 1) grid.attach(Gtk.Label(label="Working Dir", xalign=0), 0, 2, 1, 1) self.working_directory_entry = Gtk.Entry() self.working_directory_entry.set_placeholder_text("Optional working directory") self.working_directory_entry.connect("changed", self._on_settings_changed) - grid.attach(self.working_directory_entry, 1, 2, 3, 1) + grid.attach(self.working_directory_entry, 1, 2, 2, 1) grid.attach(Gtk.Label(label="Country", xalign=0), 0, 3, 1, 1) self.country_entry = Gtk.Entry() @@ -204,40 +299,50 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): self.release_id_entry = Gtk.Entry() self.release_id_entry.set_placeholder_text("Optional release override") self.release_id_entry.connect("changed", self._on_settings_changed) - grid.attach(self.release_id_entry, 3, 3, 1, 1) + grid.attach(self.release_id_entry, 3, 3, 3, 1) return grid def _build_actions(self): - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + box.get_style_context().add_class("eac-toolbar") - self.read_button = Gtk.Button(label="Read Disc") + self.read_button = Gtk.Button(label="Detect TOC") self.read_button.connect("clicked", self._on_read_clicked) self.read_button.set_tooltip_text("Read TOC and fetch matching MusicBrainz releases") box.pack_start(self.read_button, False, False, 0) - self.rip_button = Gtk.Button(label="Rip Selected Release") + self.rip_button = Gtk.Button(label="Secure Rip") self.rip_button.connect("clicked", self._on_rip_clicked) self.rip_button.set_tooltip_text("Rip the current disc using the selected release metadata") self.rip_button.set_sensitive(False) + self.rip_button.get_style_context().add_class("suggested-action") box.pack_start(self.rip_button, False, False, 0) - self.stop_button = Gtk.Button(label="Stop") + self.stop_button = Gtk.Button(label="Abort") self.stop_button.connect("clicked", self._on_stop_clicked) self.stop_button.set_tooltip_text("Cancel the current scan or rip") self.stop_button.set_sensitive(False) + self.stop_button.get_style_context().add_class("destructive-action") box.pack_start(self.stop_button, False, False, 0) - self.status_label = Gtk.Label(label="Idle", xalign=0) - box.pack_start(self.status_label, True, True, 0) + box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 6) + + action_hint = Gtk.Label( + label="EAC-style flow: Detect TOC -> pick release -> Secure Rip", + xalign=0, + ) + action_hint.get_style_context().add_class("eac-subtle") + box.pack_start(action_hint, True, True, 0) return box def _build_progress(self): - frame = Gtk.Frame(label="Progress") - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=10) + frame = Gtk.Frame(label="Extraction Progress") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=8) frame.add(box) self.progress_label = Gtk.Label(label="No task running", xalign=0) + self.progress_label.get_style_context().add_class("eac-status") box.pack_start(self.progress_label, False, False, 0) self.overall_bar = Gtk.ProgressBar(show_text=True) @@ -251,11 +356,18 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): return frame def _build_main_content(self): + pane = Gtk.Paned.new(Gtk.Orientation.VERTICAL) + pane.set_wide_handle(True) + pane.pack1(self._build_workspace(), resize=True, shrink=False) + pane.pack2(self._build_log(), resize=False, shrink=False) + self.main_pane = pane + return pane + + def _build_workspace(self): pane = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL) pane.set_wide_handle(True) - pane.pack1(self._build_left_panel(), resize=True, shrink=False) + pane.pack1(self._build_left_panel(), resize=False, shrink=False) pane.pack2(self._build_right_panel(), resize=True, shrink=False) - self.main_pane = pane return pane def _build_left_panel(self): @@ -281,17 +393,24 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): box.pack_start(info_frame, False, False, 0) - release_frame = Gtk.Frame(label="Matching Releases") + release_frame = Gtk.Frame(label="Metadata / Releases") release_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8) release_frame.add(release_box) self.release_store = Gtk.ListStore(str, str, str, str, str, object) self.release_view = Gtk.TreeView(model=self.release_store) + self.release_view.set_headers_clickable(False) + self.release_view.set_enable_search(False) + self.release_view.set_fixed_height_mode(True) + self.release_view.set_grid_lines(Gtk.TreeViewGridLines.BOTH) + self.release_view.get_style_context().add_class("eac-matrix") self.release_view.get_selection().connect("changed", self._on_release_selected) for index, title in enumerate(["Artist", "Title", "Year", "Type", "Country"]): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(title, renderer, text=index) column.set_resizable(True) + if title in {"Artist", "Title"}: + column.set_expand(True) self.release_view.append_column(column) scroll = Gtk.ScrolledWindow() @@ -310,25 +429,46 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): self.release_details = Gtk.Label(label="Select a release to inspect it.", xalign=0, yalign=0) self.release_details.set_line_wrap(True) self.release_details.set_selectable(True) + self.release_details.get_style_context().add_class("eac-subtle") details_scroll.add(self.release_details) release_box.pack_start(details_frame, False, False, 0) box.pack_start(release_frame, True, True, 0) + box.set_size_request(420, -1) return box def _build_right_panel(self): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - tracks_frame = Gtk.Frame(label="Tracks") - tracks_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8) + tracks_frame = Gtk.Frame(label="Track Extraction Matrix") + tracks_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=8) tracks_frame.add(tracks_box) - self.track_store = Gtk.ListStore(str, str, str, str) + self.track_columns = { + "number": 0, + "status": 1, + "artist": 2, + "title": 3, + "length": 4, + "test_crc": 5, + "copy_crc": 6, + "accuraterip": 7, + } + self.track_store = Gtk.ListStore(str, str, str, str, str, str, str, str) self.track_view = Gtk.TreeView(model=self.track_store) - for index, title in enumerate(["#", "Artist", "Title", "Length"]): + self.track_view.set_headers_clickable(False) + self.track_view.set_enable_search(False) + self.track_view.set_fixed_height_mode(True) + self.track_view.set_grid_lines(Gtk.TreeViewGridLines.BOTH) + self.track_view.get_style_context().add_class("eac-matrix") + for index, title in enumerate(["#", "Status", "Artist", "Title", "Length", "Test CRC", "Copy CRC", "AR"]): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(title, renderer, text=index) column.set_resizable(True) + if title in {"Artist", "Title"}: + column.set_expand(True) + if title == "Status": + column.set_min_width(140) self.track_view.append_column(column) track_scroll = Gtk.ScrolledWindow() @@ -337,16 +477,16 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): track_scroll.add(self.track_view) tracks_box.pack_start(track_scroll, True, True, 0) + tracks_box.pack_start(self._build_rip_options(), False, False, 0) box.pack_start(tracks_frame, True, True, 0) - box.pack_start(self._build_rip_options(), False, False, 0) return box def _build_rip_options(self): - frame = Gtk.Frame(label="Rip Options") - grid = Gtk.Grid(column_spacing=10, row_spacing=8, margin=10) + frame = Gtk.Frame(label="Extraction Setup") + grid = Gtk.Grid(column_spacing=8, row_spacing=6, margin=8) frame.add(grid) - self.unknown_check = Gtk.CheckButton(label="Allow ripping without metadata") + self.unknown_check = Gtk.CheckButton(label="Allow unknown disc") self.unknown_check.set_active(True) self.unknown_check.connect("toggled", self._on_unknown_toggled) grid.attach(self.unknown_check, 0, 0, 2, 1) @@ -355,7 +495,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): self.cdr_check.connect("toggled", self._on_settings_changed) grid.attach(self.cdr_check, 2, 0, 1, 1) - self.keep_going_check = Gtk.CheckButton(label="Keep going on failed tracks") + self.keep_going_check = Gtk.CheckButton(label="Continue on failed tracks") self.keep_going_check.set_active(True) self.keep_going_check.connect("toggled", self._on_settings_changed) grid.attach(self.keep_going_check, 0, 1, 2, 1) @@ -386,14 +526,6 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): self.offset_spin.connect("value-changed", self._on_settings_changed) grid.attach(self.offset_spin, 1, 3, 1, 1) - grid.attach(Gtk.Label(label="Logger", xalign=0), 2, 3, 1, 1) - self.logger_combo = Gtk.ComboBoxText() - for logger_name in sorted(result.getLoggers()): - self.logger_combo.append(logger_name, logger_name) - self.logger_combo.set_active_id("whipper") - self.logger_combo.connect("changed", self._on_settings_changed) - grid.attach(self.logger_combo, 3, 3, 1, 1) - grid.attach(Gtk.Label(label="Track Tpl", xalign=0), 0, 4, 1, 1) self.track_template_entry = Gtk.Entry() self.track_template_entry.set_text(cd_command.DEFAULT_TRACK_TEMPLATE) @@ -407,25 +539,28 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): grid.attach(self.disc_template_entry, 1, 5, 3, 1) note = Gtk.Label( - label="The configured drive offset is loaded automatically when whipper knows this drive.", + label="Secure extraction is driven by whipper's native cdparanoia/cdrdao pipeline. The configured drive offset is loaded automatically when available.", xalign=0, ) note.set_line_wrap(True) + note.get_style_context().add_class("eac-subtle") grid.attach(note, 0, 6, 4, 1) return frame def _build_log(self): - frame = Gtk.Frame(label="Log") + frame = Gtk.Frame(label="Extraction Log") scroll = Gtk.ScrolledWindow() scroll.set_hexpand(True) - scroll.set_vexpand(True) + scroll.set_vexpand(False) + scroll.set_min_content_height(220) frame.add(scroll) text_view = Gtk.TextView() text_view.set_editable(False) text_view.set_cursor_visible(False) text_view.set_monospace(True) + text_view.get_style_context().add_class("eac-log") self.log_buffer = text_view.get_buffer() scroll.add(text_view) return frame @@ -621,11 +756,24 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): for index, track in enumerate(metadata.tracks, start=1): self.track_store.append([ str(index), + "Ready", track.artist or "", track.title or "", _format_duration_ms(track.duration), + "", + "", + "", ]) + def _set_track_field(self, track_number, field, value): + if track_number <= 0 or self.track_store is None or self.track_columns is None: + return False + row_index = track_number - 1 + if row_index < 0 or row_index >= len(self.track_store): + return False + self.track_store[row_index][self.track_columns[field]] = value + return False + def _update_release_details(self, metadata): if metadata is None: self.release_details.set_text("Select a release to inspect it.") @@ -836,6 +984,14 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): self.track_bar.set_fraction(value) self.track_bar.set_text(description) self.progress_label.set_text("%s: %s" % (item_label, description)) + if item_label.startswith("Track "): + try: + track_number = int(item_label.split()[1]) + except (IndexError, ValueError): + track_number = None + if track_number is not None: + percent = int(value * 100) + self._set_track_field(track_number, "status", "%s %d%%" % (description, percent)) return False def _mark_track_finished(self, item_label, item_index, item_total, skipped=False): @@ -846,6 +1002,13 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): self.track_bar.set_fraction(1.0) self.track_bar.set_text("%s %s" % (item_label, "skipped" if skipped else "done")) self.progress_label.set_text("%s %s" % (item_label, "failed" if skipped else "verified")) + if item_label.startswith("Track "): + try: + track_number = int(item_label.split()[1]) + except (IndexError, ValueError): + track_number = None + if track_number is not None: + self._set_track_field(track_number, "status", "Skipped" if skipped else "Verified") return False def _finish_rip(self, returncode, status="Done"): @@ -891,6 +1054,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): if os.path.exists(path): GLib.idle_add(self._append_log, "%s already exists, verifying\n" % item_label) + if track_number > 0: + GLib.idle_add(self._set_track_field, track_number, "status", "Verifying existing") if not program.verifyTrack(runner, track_result): GLib.idle_add(self._append_log, "%s verification failed, reripping\n" % item_label) os.unlink(path) @@ -904,6 +1069,13 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): raise RipCancelledError("Rip cancelled") extra = "" if tries == 1 else " (try %d)" % tries GLib.idle_add(self._append_log, "%s%s: %s\n" % (item_label, extra, os.path.basename(path))) + if track_number > 0: + GLib.idle_add( + self._set_track_field, + track_number, + "status", + "Test & Copy%s" % extra, + ) tag_list = program.getTagList(track_number, mbdiscid) if track_number > 0 and itable.tracks[track_number - 1].isrc is not None: @@ -940,6 +1112,19 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): track_result.copyduration += rip_task.copyduration if track_result.filename != rip_task.path: track_result.filename = rip_task.path + if track_number > 0: + GLib.idle_add( + self._set_track_field, + track_number, + "test_crc", + "%08X" % track_result.testcrc if track_result.testcrc is not None else "", + ) + GLib.idle_add( + self._set_track_field, + track_number, + "copy_crc", + "%08X" % track_result.copycrc if track_result.copycrc is not None else "", + ) break except Exception as exc: if self.rip_cancel_requested: @@ -953,6 +1138,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): if settings["keep_going"]: track_result.skipped = True skipped_tracks.append(track_result) + if track_number > 0: + GLib.idle_add(self._set_track_field, track_number, "accuraterip", "Skipped") GLib.idle_add(self._mark_track_finished, item_label, item_index, item_total, True) return raise RuntimeError("%s can't be ripped" % item_label) @@ -962,6 +1149,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): return if track_result.testcrc != track_result.copycrc: + if track_number > 0: + GLib.idle_add(self._set_track_field, track_number, "accuraterip", "CRC mismatch") raise RuntimeError("CRCs did not match for %s" % item_label) if track_number == 0: @@ -985,6 +1174,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object): program.result.table.getTrackLength(track_number), track_number, ) + GLib.idle_add(self._set_track_field, track_number, "accuraterip", "Pending AR") GLib.idle_add(self._mark_track_finished, item_label, item_index, item_total, False) diff --git a/whipper/test/test_gui.py b/whipper/test/test_gui.py index ce675fd..9b70f34 100644 --- a/whipper/test/test_gui.py +++ b/whipper/test/test_gui.py @@ -277,6 +277,16 @@ def _make_ui_app(tmp_path): app.track_bar = FakeProgressBar() app.release_store = FakeListStore() app.track_store = FakeListStore() + app.track_columns = { + "number": 0, + "status": 1, + "artist": 2, + "title": 3, + "length": 4, + "test_crc": 5, + "copy_crc": 6, + "accuraterip": 7, + } app.release_view = FakeReleaseView(app.release_store) app.release_details = FakeLabel() app.info_labels = {key: FakeLabel() for key in [ @@ -305,6 +315,7 @@ def _make_ui_app(tmp_path): "_set_running_state", "_update_release_store", "_update_track_store", + "_set_track_field", "_update_release_details", "_update_rip_task_progress", "_finish_rip", @@ -390,7 +401,8 @@ def test_release_selection_and_progress_updates(monkeypatch, tmp_path): gui.WhipperGui._finish_rip(app, 0, "Done") assert app.current_release is metadata - assert app.track_store[0][1] == "Track Artist" + assert app.track_store[0][2] == "Track Artist" + assert app.track_store[0][1].startswith("Encoding") assert app.release_details.get_text().startswith("Artist: Artist") assert app.overall_bar.fraction == 1.0 assert app.track_bar.fraction == 1.0