diff --git a/README.md b/README.md index e408c94..6ef12b6 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ set next to it. | What | How | | --- | --- | | Start / stop recording | `Ctrl+Space`, or click the tray icon | +| Pause / resume the recording | Tray menu, `dikte pause`, or a key you set | | Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` | | Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | diff --git a/README.tr.md b/README.tr.md index db1f23c..0ac4d6b 100644 --- a/README.tr.md +++ b/README.tr.md @@ -103,6 +103,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin. | Ne | Nasıl | | --- | --- | | Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla | +| Kaydı duraklat / sürdür | Tepsi menüsü, `dikte pause`, ya da atadığın bir tuş | | Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` | | Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | diff --git a/audio.py b/audio.py index 511bdf1..bcd43b0 100644 --- a/audio.py +++ b/audio.py @@ -72,12 +72,31 @@ class Recorder(QObject): self._rms = [] self._cancelled = False self._stopping = False + self._paused = False self._lock = threading.Lock() @property def active(self): return self._thread is not None and self._thread.is_alive() + @property + def paused(self): + return self._paused + + def pause(self, value=True): + """Stop taking sound in without letting go of the microphone. + + The capture program keeps running and keeps handing blocks over; they + are dropped as they arrive rather than kept. Stopping it instead would + mean asking the sound server for the device again on the way back, and + that is the one moment another application can take it: a recording + would be lost to the phone call it was paused for. + + What was said while it was paused is gone, which is the point. The two + halves meet as one splice, with none of the room in between. + """ + self._paused = bool(value) + def start(self, target="", max_seconds=300): if self.active: return @@ -102,6 +121,7 @@ class Recorder(QObject): self._rms = [] self._cancelled = False self._stopping = False + self._paused = False self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS) self._thread = threading.Thread(target=self._pump, daemon=True) self._thread.start() @@ -114,6 +134,11 @@ class Recorder(QObject): chunk = stdout.read(CHUNK_BYTES) if not chunk: break + if self._paused: + # Read and thrown away rather than left in the pipe: a pipe + # nobody empties fills up, and the capture program blocks on + # a full one instead of waiting quietly for the resume. + continue peak, rms = chunk_levels(chunk) with self._lock: self._buffer.extend(chunk) diff --git a/cli.py b/cli.py index e122905..ba643c2 100644 --- a/cli.py +++ b/cli.py @@ -43,7 +43,7 @@ GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"} # Asking a process that is not there to stop, cancel or quit is not a failure; # it is already in the state that was asked for. IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel", - "ask-reset", "meeting-cancel"} + "ask-reset", "meeting-cancel", "pause"} _app = None @@ -765,7 +765,8 @@ def cmd_status(opts): return fail(opts, "the running instance is from before it could answer " "questions; reload it with: dikte restart", 1, running=True) lines = [ - f"dictation: {reply.get('dictation', '?')}", + f"dictation: {reply.get('dictation', '?')}" + + (" (paused)" if reply.get("paused") else ""), f"agent: {reply.get('ask', '?')} ({reply.get('agent', '?')})", f"meeting: {reply.get('meeting', '?')}" + (f" {reply['meeting_message']}" if reply.get("meeting_message") else ""), @@ -879,6 +880,10 @@ def build_parser(): page.add_argument("--timeout", type=float, default=0) page.set_defaults(func=cmd_toggle) + # One verb for both halves, the way `toggle` is one verb: a key can only be + # pressed, so a key that resumed nothing would need a second key. + leaf(subs, "pause", "hold the recording, or take it up again" + ).set_defaults(func=cmd_plain) leaf(subs, "cancel", "throw away the recording").set_defaults(func=cmd_cancel) # --- the agent -------------------------------------------------------- diff --git a/config.py b/config.py index 4dd4088..4861a75 100644 --- a/config.py +++ b/config.py @@ -435,6 +435,10 @@ DEFAULTS = { # trick lands on the toggle, Alt and Option being one key, so discarding # gets a letter instead. "cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space", + # Empty -> tray only. Holding a recording is not something a keyboard has a + # habit for, and a combination nobody asked for is one taken away from + # whatever else was using it. + "pause_shortcut": "", "evdev_hotkey": False, "overlay_corner": "bottom-left", "keep_audio": False, diff --git a/dikte.py b/dikte.py index ee64b40..4718c46 100755 --- a/dikte.py +++ b/dikte.py @@ -83,6 +83,14 @@ class Dikte: self.ask_state = IDLE # Which of the two the microphone is currently serving, or None. self.recorder_owner = None + # A recording that is running but taking nothing in. Not a state of its + # own: everything that can be done to a recording can be done to a + # paused one, and a fourth state would have to say so four times over. + self.paused = False + # Paused time, which is time the recording does not have: the clock on + # screen and the limit both go by what was actually captured. + self._paused_ms = 0 + self._paused_at = 0 self.meeting_state = M_IDLE self.meeting_base = "" self.meeting_message = "" @@ -159,6 +167,12 @@ class Dikte: self.toggle_action.triggered.connect(self._toggle) self.menu.addAction(self.toggle_action) + # Named in _refresh_tray as well, since it says one of two things. + self.pause_action = QAction(t("Pause the recording"), self.menu) + self.pause_action.triggered.connect(self._toggle_pause) + self.pause_action.setEnabled(False) + self.menu.addAction(self.pause_action) + # Named in _refresh_tray, which is where the chosen provider is known. self.ask_action = QAction("", self.menu) self.ask_action.triggered.connect(self._toggle_ask) @@ -283,6 +297,9 @@ class Dikte: or (self.ask_state == IDLE and not self.recording) ) self.reset_action.setEnabled(self.ask_state != BUSY) + self.pause_action.setText(t("Resume the recording") if self.paused + else t("Pause the recording")) + self.pause_action.setEnabled(self.recording) self.cancel_action.setEnabled(self.recording) # A command to the agent is the one job long enough to be worth calling # off once it is already running. @@ -299,6 +316,12 @@ class Dikte: else: icon, tip = "view-refresh", "Dikte: talking to Claude" + # Whichever of the two is holding the microphone, a recording dot that + # keeps burning while nothing goes in is the icon telling the opposite + # of what is happening. + if self.paused and self.recording: + icon, tip = "media-playback-pause", "Dikte: paused" + meeting_labels = { M_IDLE: "Record a meeting", M_RECORDING: "End the meeting and write it up", @@ -334,6 +357,9 @@ class Dikte: def toggle_meeting(self): self._external("meeting", self._toggle_meeting) + def toggle_pause(self): + self._external("pause", self._toggle_pause) + def cancel(self): self._external("cancel", self._cancel) @@ -358,7 +384,7 @@ class Dikte: timer = self.last_evdev[name] = QElapsedTimer() timer.restart() handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask, - "cancel": self._cancel} + "cancel": self._cancel, "pause": self._toggle_pause} handlers.get(name, self._toggle)() def _retire_listener(self): @@ -393,6 +419,7 @@ class Dikte: else: handler = { "cancel": self.cancel, + "pause": self.toggle_pause, "ask-cancel": self.cancel_ask, "ask-reset": self.reset_conversation, "meeting-cancel": self.cancel_meeting, @@ -475,6 +502,7 @@ class Dikte: "ok": True, "running": True, "dictation": self.state, + "paused": self.paused, "ask": self.ask_state, "meeting": self.meeting_state, "meeting_base": self.meetings.running_base, @@ -538,6 +566,7 @@ class Dikte: """One microphone, so one of the two holds it at a time.""" self.recorder_owner = owner self._run_id += 1 + self._clear_pause() self.elapsed.restart() self.ticker.start() self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) @@ -546,6 +575,7 @@ class Dikte: if self.state != RECORDING: return self.ticker.stop() + self._clear_pause() self._set_state(BUSY) self.overlay.show_busy(t("Transcribing…")) self.recorder.stop() @@ -554,16 +584,50 @@ class Dikte: if self.ask_state != RECORDING: return self.ticker.stop() + self._clear_pause() self._set_ask_state(BUSY) self.ask_overlay.show_busy(t("Transcribing…")) self.recorder.stop() + def _toggle_pause(self): + """Hold the recording where it is, or take it up again. + + A pause is not a stop: the microphone stays ours and what has been said + so far stays in the buffer. What is said while it is held is dropped, so + the phone call in the middle of a dictation never reaches the model and + the sentence around it is still one sentence. + """ + if not self.recording or self._repeated(): + return + self.paused = not self.paused + if self.paused: + self._paused_at = self.elapsed.elapsed() + else: + self._paused_ms += self.elapsed.elapsed() - self._paused_at + self.recorder.pause(self.paused) + self._recording_overlay().set_paused(self.paused) + self._refresh_tray() + + def _clear_pause(self): + """Every recording starts and ends taking sound in.""" + self.paused = False + self._paused_ms = 0 + self._paused_at = 0 + + def _recorded_seconds(self): + """Wall clock less whatever was held: the length of what will be + transcribed, which is what the limit has to be measured against too.""" + # A held recording is as long now as it was when it was held. + now = self._paused_at if self.paused else self.elapsed.elapsed() + return max(0.0, (now - self._paused_ms) / 1000.0) + def _cancel(self): """Throw away whichever recording is running.""" if not self.recording: return asking = self.ask_state == RECORDING self.ticker.stop() + self._clear_pause() self.recorder.cancel() self.recorder_owner = None # What goes over the socket is read by a program as often as by a @@ -603,7 +667,7 @@ class Dikte: self._recording_overlay().push_level(level) def _tick(self): - seconds = self.elapsed.elapsed() / 1000.0 + seconds = self._recorded_seconds() self._recording_overlay().set_seconds(seconds) if seconds >= self.conf["max_seconds"]: (self.stop_ask if self.recorder_owner == ASK else self.stop)() diff --git a/hotkey.py b/hotkey.py index 0cbcfc4..7026dee 100644 --- a/hotkey.py +++ b/hotkey.py @@ -29,6 +29,7 @@ from i18n import t DESKTOP_ID = "dikte-toggle.desktop" CANCEL_DESKTOP_ID = "dikte-cancel.desktop" +PAUSE_DESKTOP_ID = "dikte-pause.desktop" MEETING_DESKTOP_ID = "dikte-meeting.desktop" ASK_DESKTOP_ID = "dikte-ask.desktop" APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" @@ -39,7 +40,7 @@ GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keyb Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback") -# Every global shortcut in one place, because there are four of them and the +# Every global shortcut in one place, because there are five of them and the # command line, the settings window and the installer each used to carry their # own copy of the list. `fallback` is what to register when the setting is # empty: only the toggle has one, since it is the key the application is @@ -47,6 +48,8 @@ Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fall SHORTCUTS = { "toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording", "shortcut", "Ctrl+Space"), + "pause": Shortcut("pause", PAUSE_DESKTOP_ID, + "Dikte: pause/resume the recording", "pause_shortcut", ""), "cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording", "cancel_shortcut", ""), "ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code", diff --git a/i18n.py b/i18n.py index 7ce2b3a..8f6aa89 100644 --- a/i18n.py +++ b/i18n.py @@ -56,12 +56,15 @@ TR = { "Start recording": "Kaydı başlat", "Stop and transcribe": "Kaydı bitir ve yaz", "Working…": "İşleniyor…", + "Pause the recording": "Kaydı duraklat", + "Resume the recording": "Kayda devam et", "Discard the recording": "Kaydı iptal et", "Settings…": "Ayarlar…", "Restart": "Yeniden başlat", "Quit": "Çık", "Dikte: ready": "Dikte: hazır", "Dikte: recording": "Dikte: kaydediyor", + "Dikte: paused": "Dikte: duraklatıldı", "Dikte: working": "Dikte: işleniyor", # --- overlay / pipeline ------------------------------------------- @@ -312,7 +315,14 @@ TR = { "Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.", "No global shortcut installed. The tray menu discards it too.": "Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.", + "No global shortcut installed. The tray menu holds it too.": + "Global kısayol kurulu değil. Kayıt tepsi menüsünden de duraklatılabilir.", "Start and stop": "Başlat ve bitir", + "Pause and resume": "Duraklat ve devam et", + "Holds the recording without ending it. Nothing said while it is paused is " + "kept, and the clock stops with it.": + "Kaydı bitirmeden duraklatır. Duraklatıldığı sürede konuşulanlar " + "kaydedilmez, süre sayacı da onunla birlikte durur.", "Throws the recording away without transcribing it. Works on a dictation " "and on a command for the agent alike, whichever is running.": "Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, " diff --git a/overlay.py b/overlay.py index f843306..a46cb6d 100644 --- a/overlay.py +++ b/overlay.py @@ -26,6 +26,9 @@ WARN = QColor(240, 180, 80) THEM = QColor(110, 190, 255) # the other side of a meeting ASK = QColor(150, 140, 255) # recording a command rather than a dictation +# Recording, but nothing is going in. The same amber a warning gets, and for +# the same reason: it is the colour that stops you walking away from it. +HELD = WARN STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY, "done": OK, "warning": WARN, "error": ERR} @@ -47,6 +50,10 @@ class Overlay(QWidget): self.dismissable = dismissable self.muted = False self._stacked = False + # A pause is not a state of its own: what is on screen is still the + # recording, held. Keeping it beside the state is what lets the ribbon + # stay where the pause found it instead of being cleared and rebuilt. + self.paused = False self.state = "idle" self.message = "" self.levels = [0.0] * BARS @@ -102,6 +109,7 @@ class Overlay(QWidget): self.message = "" self.seconds = 0.0 self.levels = [0.0] * BARS + self.paused = False self.muted = False # a new run starts visible, whatever the last one did self._hide_timer.stop() self._appear() @@ -113,6 +121,7 @@ class Overlay(QWidget): self.seconds = 0.0 self.levels = [0.0] * BARS self.levels2 = [0.0] * BARS + self.paused = False self._hide_timer.stop() self._appear() @@ -174,6 +183,17 @@ class Overlay(QWidget): def set_seconds(self, seconds): self.seconds = seconds + def set_paused(self, paused): + """Held, or taking sound in again. + + Everything about the ribbon says a recording is running: a dot that + pulses, bars that move, a clock that counts. A pause that only stopped + the sound would leave all three saying the words are still going in, so + it is the ribbon that has to say otherwise. + """ + self.paused = bool(paused) + self.update() + # ---- internals ----------------------------------------------------- def _appear(self): @@ -243,11 +263,11 @@ class Overlay(QWidget): # the corner when it does rather than leaving a gap where it was. if self.below is not None and self.below.showing != self._stacked: self._reposition() - if self.state in LIVE: + if self.state in LIVE and not self.paused: # keep the ribbon moving even through a pause in speech self.levels = self.levels[1:] + [self.levels[-1] * 0.72] - if self.state == "meeting": - self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72] + if self.state == "meeting": + self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72] self.update() def _label_font(self): @@ -272,6 +292,8 @@ class Overlay(QWidget): painter.drawPath(path) accent = STATE_COLORS.get(self.state, MUTED) + if self._held: + accent = HELD self._draw_indicator(painter, accent) if self.state in LIVE: @@ -282,10 +304,23 @@ class Overlay(QWidget): if self._can_dismiss: self._draw_dismiss(painter) + @property + def _held(self): + """A recording that is paused. Nothing else can be.""" + return self.paused and self.state in LIVE + def _draw_indicator(self, painter, accent): cx, cy = 26.0, self.height() / 2 painter.setPen(Qt.PenStyle.NoPen) - if self.state in LIVE: + if self._held: + # The two bars everything that plays sound uses, and no glow: a + # pulse is what says a recording is live. + painter.setBrush(accent) + for offset in (-4.4, 1.4): + painter.drawRoundedRect( + QRectF(cx + offset, cy - 6.5, 3.0, 13.0), 1.2, 1.2 + ) + elif self.state in LIVE: pulse = 0.62 + 0.38 * (0.5 + 0.5 * math.sin(self._phase * 1.6)) glow = QColor(accent) glow.setAlphaF(0.22 * pulse) diff --git a/settings_ui.py b/settings_ui.py index 8571a1a..b8610ce 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1321,6 +1321,14 @@ class SettingsWindow(QDialog): form, "toggle", t("Start and stop"), t("No global shortcut installed."), placeholder="Ctrl+Space", ) + # The point of holding a recording is that something else came up, and + # something else is exactly when a hand is not free for a menu. + self._shortcut_row( + form, "pause", t("Pause and resume"), + t("No global shortcut installed. The tray menu holds it too."), + tooltip=t("Holds the recording without ending it. Nothing said " + "while it is paused is kept, and the clock stops with it."), + ) # Stopping is what sends the recording off to be transcribed, and that # is the step there is no taking back. By the time the tray menu is # open the sentence you did not mean to dictate is already on its way. @@ -1700,8 +1708,8 @@ class SettingsWindow(QDialog): conf["file_cleanup"] = self.file_cleanup.isChecked() # Left empty, only the toggle falls back to a default: the application - # is unusable without it. The other three stay empty, which is what - # turns them off. + # is unusable without it. The rest stay empty, which is what turns + # them off. for which, (box, _status, _missing) in self._shortcut_rows.items(): spec = hotkey.SHORTCUTS[which] conf[spec.setting] = (box.currentText().strip() diff --git a/tests/test_audio.py b/tests/test_audio.py index 5bda0ab..ea2454a 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -280,6 +280,26 @@ class _StalledStream: self._released.set() +class _HeldStream: + """A capture that is paused and taken up again partway through, the way a + key press lands in the middle of a recording rather than between two.""" + + def __init__(self, data, recorder, pause_at, resume_at=None): + self._data = io.BytesIO(data) + self._recorder = recorder + self._pause_at = pause_at + self._resume_at = resume_at + self.reads = 0 + + def read(self, size): + if self.reads == self._pause_at: + self._recorder.pause() + elif self.reads == self._resume_at: + self._recorder.pause(False) + self.reads += 1 + return self._data.read(size) + + class RecordingCommand(OnLinux, DikteTest): """Which program captures the microphone, and how it is asked to.""" @@ -499,6 +519,51 @@ class RecorderChain(OnLinux, DikteTest): self.assertEqual(len(failures), 1) self.assertIn("0.3", failures[0]) + def held(self, data, pause_at, resume_at=None): + """Record `data` with the recorder paused for part of it.""" + recorder = audio.Recorder() + results = [] + failures = [] + recorder.stopped.connect(lambda *args: results.append(args)) + recorder.failed.connect(failures.append) + proc = FakeProcess(data) + proc.stdout = _HeldStream(data, recorder, pause_at, resume_at) + with only_these_tools("pw-record"), \ + mock.patch.object(subprocess, "Popen", return_value=proc): + recorder.start() + recorder._thread.join(timeout=5) + # Nothing has ended the capture: a pause holds the microphone. + self.assertEqual(proc.signals, []) + recorder.stop() + return results, failures + + def test_what_is_said_while_it_is_held_is_not_in_the_recording(self): + """The phone call in the middle of a dictation is the whole feature: it + must not reach the transcript, and the two halves must meet.""" + results, failures = self.held(tone(2.0), pause_at=8, resume_at=16) + self.assertEqual(failures, []) + path, duration, _ = results[0] + self.addCleanup(os.unlink, path) + dropped = 8 * audio.CHUNK_FRAMES + self.assertAlmostEqual(duration, (2 * audio.RATE - dropped) / audio.RATE, + places=3) + + def test_a_recording_held_all_the_way_through_captured_nothing(self): + results, failures = self.held(tone(2.0), pause_at=0) + self.assertEqual(results, []) + self.assertIn("0.3", failures[0]) + + def test_a_pause_does_not_outlive_the_recording_it_was_asked_for(self): + recorder = audio.Recorder() + recorder.pause() + proc = FakeProcess(tone(0.5)) + with only_these_tools("pw-record"), \ + mock.patch.object(subprocess, "Popen", return_value=proc): + recorder.start() + self.assertFalse(recorder.paused) + recorder._thread.join(timeout=5) + recorder.cancel() + def test_a_recorder_that_could_not_start(self): recorder = audio.Recorder() failures = [] diff --git a/tests/test_cli.py b/tests/test_cli.py index ebfce3b..15e62b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -162,7 +162,7 @@ class Parser(unittest.TestCase): name) def test_every_verb_is_wired_to_something(self): - for verb in ("record", "toggle", "start", "stop", "cancel", "ask", + for verb in ("record", "toggle", "start", "stop", "pause", "cancel", "ask", "session", "transcribe", "meeting", "meetings", "history", "config", "prompt", "devices", "models", "test-key", "doctor", "shortcut", "status", "settings", "restart", @@ -614,6 +614,14 @@ class Replies(DikteTest): captured(): self.assertEqual(cli.run(["cancel"]), 0) + def test_pausing_a_recording_nobody_started_is_not_a_failure_either(self): + """A key that pauses can be pressed when there is nothing to pause, and + it must not start an application to tell you so.""" + with mock.patch.object(ipc, "send", return_value=None), \ + mock.patch.object(cli, "launch_gui") as launched, captured(): + self.assertEqual(cli.run(["pause"]), 0) + self.assertFalse(launched.called) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index b06cc63..9159f6b 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -96,6 +96,7 @@ CHANGED = { "file_cleanup": False, "shortcut": "Ctrl+Alt+Space", "cancel_shortcut": "Meta+Shift+Space", + "pause_shortcut": "Meta+P", "evdev_hotkey": True, "history_limit": 50, } @@ -269,6 +270,7 @@ class Settings(DikteTest): self.assertTrue(conf["shortcut"]) self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle")) self.assertEqual(conf["cancel_shortcut"], "") + self.assertEqual(conf["pause_shortcut"], "") self.assertEqual(conf["assistant_shortcut"], "") self.assertEqual(conf["meeting_shortcut"], "") @@ -466,6 +468,27 @@ class Overlay(DikteTest): widget._conceal() self.assertFalse(widget.showing) + def test_a_held_recording_says_so_and_stops_moving(self): + """Everything about the ribbon says a recording is running; a pause the + ribbon did not show would leave all of it saying the opposite.""" + widget = self.overlay() + widget.show_recording() + widget.push_level(0.8) + widget.set_paused(True) + levels = list(widget.levels) + widget._tick() + self.assertEqual(widget.levels, levels) + widget.set_paused(False) + widget._tick() + self.assertNotEqual(widget.levels, levels) + + def test_a_new_recording_is_never_the_last_one_still_held(self): + widget = self.overlay() + widget.show_recording() + widget.set_paused(True) + widget.show_recording() + self.assertFalse(widget.paused) + def test_a_meeting_shows_both_sides(self): widget = self.overlay() widget.show_meeting() diff --git a/trayicon.py b/trayicon.py index b5aafc2..d016b6a 100644 --- a/trayicon.py +++ b/trayicon.py @@ -1,11 +1,11 @@ -"""The three tray icons, drawn here for systems that have no icon theme. +"""The four tray icons, drawn here for systems that have no icon theme. -Linux hands out `audio-input-microphone`, `media-record` and `view-refresh` -from whatever icon theme is installed, and Qt finds them through -QIcon.fromTheme. macOS has no such registry: fromTheme returns a null icon -there, and a null icon in the menu bar is an item you cannot see, which is the -whole of Dikte's interface gone. So the same three shapes are drawn here, and -used whenever the theme has nothing to offer. +Linux hands out `audio-input-microphone`, `media-record`, `view-refresh` and +`media-playback-pause` from whatever icon theme is installed, and Qt finds them +through QIcon.fromTheme. macOS has no such registry: fromTheme returns a null +icon there, and a null icon in the menu bar is an item you cannot see, which is +the whole of Dikte's interface gone. So the same four shapes are drawn here, +and used whenever the theme has nothing to offer. They are drawn as template images: one colour, transparent everywhere else, with isMask set. That is what lets macOS invert them for a dark menu bar and @@ -75,6 +75,18 @@ def _record(painter, size): painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit) +def _paused(painter, size): + """Two bars: the recording is still ours, and nothing is going into it.""" + unit = size / 22.0 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(INK) + for left in (6.4, 12.4): + painter.drawRoundedRect( + QRectF(left * unit, 5.0 * unit, 3.2 * unit, 12.0 * unit), + 1.2 * unit, 1.2 * unit, + ) + + def _working(painter, size): """An arrow chasing its own circle: transcribing, cleaning up, thinking.""" unit = size / 22.0 @@ -102,6 +114,7 @@ def _working(painter, size): SHAPES = { "audio-input-microphone": _microphone, "media-record": _record, + "media-playback-pause": _paused, "view-refresh": _working, } diff --git a/uninstall.sh b/uninstall.sh index b7e740b..a9c2ad9 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -90,7 +90,7 @@ echo "──────────────────" if ((MACOS)); then say "Nothing to unregister: macOS shortcuts live only while Dikte runs." elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then - for which in toggle cancel ask meeting; do + for which in toggle pause cancel ask meeting; do "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true done ok "Global shortcuts unregistered" @@ -151,7 +151,7 @@ if ((!MACOS)); then # Removing the shortcut takes its desktop file with it, but an install from # before this script existed may have left one behind on a desktop that never # used them. - for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do + for id in dikte-toggle dikte-pause dikte-cancel dikte-ask dikte-meeting; do if [[ -e "$APP_DIR/$id.desktop" ]]; then remove "$APP_DIR/$id.desktop" fi