# Orca # # Copyright 2026 Igalia, S.L. # Author: Joanmarie Diggs # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. """Provides a graphical speech monitor, mainly for development tasks.""" from __future__ import annotations from typing import TYPE_CHECKING, Any import gi gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gtk from . import guilabels if TYPE_CHECKING: from collections.abc import Callable MIN_FONT_SIZE = 8 MAX_FONT_SIZE = 72 ZOOM_STEP = 2 class SpeechMonitor(Gtk.Window): # pylint: disable=too-many-instance-attributes """Displays a GUI speech monitor showing a scrolling log of spoken text.""" _shared_css_provider: Gtk.CssProvider | None = None # pylint: disable-next=too-many-statements def __init__( self, font_size: int = 14, foreground: str = "#ffffff", background: str = "#000000", on_close: Callable[[], None] | None = None, ) -> None: """Create a new SpeechMonitor.""" # pylint: disable=no-member super().__init__() self._on_close = on_close self._font_size = font_size self._default_font_size = font_size self._foreground = foreground self._background = background self.set_title(guilabels.SPEECH_MONITOR) self.set_default_size(1000, 400) self.set_icon_name("orca") titlebar = Gtk.Box() self.set_titlebar(titlebar) self._close_icon = Gtk.Image.new_from_icon_name( "window-close-symbolic", Gtk.IconSize.LARGE_TOOLBAR, ) close_btn = Gtk.Button() close_btn.set_image(self._close_icon) close_btn.set_relief(Gtk.ReliefStyle.NONE) close_btn.connect("clicked", self._on_close_clicked) title_label = Gtk.Label(label=guilabels.SPEECH_MONITOR) title_label.set_halign(Gtk.Align.CENTER) drag_bar = Gtk.EventBox() drag_bar.add(title_label) drag_bar.connect("button-press-event", self._on_drag_bar_press) close_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) close_row.pack_start(drag_bar, True, True, 0) close_row.pack_end(close_btn, False, False, 0) self._text_view = Gtk.TextView() self._text_view.set_editable(False) self._text_view.set_cursor_visible(True) self._text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self._text_view.set_left_margin(6) self._text_view.set_right_margin(6) self._text_view.set_top_margin(6) self._text_view.set_bottom_margin(6) self._buffer = self._text_view.get_buffer() self._end_mark = self._buffer.create_mark("end", self._buffer.get_end_iter(), False) self._line_count = 0 scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled.add(self._text_view) border_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) border_box.pack_start(close_row, False, False, 0) border_box.pack_start(scrolled, True, True, 0) self.add(border_box) self.get_style_context().add_class("speech-monitor") titlebar.get_style_context().add_class("speech-monitor-titlebar") title_label.get_style_context().add_class("speech-monitor-title") close_btn.get_style_context().add_class("speech-monitor-close") border_box.get_style_context().add_class("speech-monitor-border") self._text_view.get_style_context().add_class("speech-monitor-text") if SpeechMonitor._shared_css_provider is None: SpeechMonitor._shared_css_provider = Gtk.CssProvider() Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), # pylint: disable=no-value-for-parameter SpeechMonitor._shared_css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) self._apply_css() self.set_keep_above(True) self.set_skip_taskbar_hint(True) self.set_skip_pager_hint(True) self.set_focus(self._text_view) self.connect("notify::is-active", self._on_window_activated) self.connect("delete-event", self._on_delete_event) self.connect("key-press-event", self._on_key_press) def _on_window_activated(self, _window: Gtk.Window, _pspec: Any) -> None: """Grab focus on the text view whenever the window becomes active.""" if self.is_active(): self._text_view.grab_focus() def _on_close_clicked(self, _button: Gtk.Button) -> None: """Handle the close button click.""" if self._on_close is not None: self._on_close() def _on_drag_bar_press(self, _widget: Gtk.Widget, event: Gdk.EventButton) -> bool: """Initiate a window move when the drag bar is clicked.""" # pylint: disable=no-member if event.button == 1: self.begin_move_drag(event.button, int(event.x_root), int(event.y_root), event.time) return True return False def _on_delete_event(self, _window: Gtk.Window, _event: Gdk.Event) -> bool: """Intercept the close request to toggle the monitor off.""" if self._on_close is not None: self._on_close() return True def _on_key_press(self, _window: Gtk.Window, event: Gdk.EventKey) -> bool: """Handle Ctrl+Plus/Minus/0 for zoom.""" if not event.state & Gdk.ModifierType.CONTROL_MASK: return False if event.keyval in (Gdk.KEY_plus, Gdk.KEY_equal, Gdk.KEY_KP_Add): self._font_size = min(MAX_FONT_SIZE, self._font_size + ZOOM_STEP) self._apply_css() return True if event.keyval in (Gdk.KEY_minus, Gdk.KEY_KP_Subtract): self._font_size = max(MIN_FONT_SIZE, self._font_size - ZOOM_STEP) self._apply_css() return True if event.keyval in (Gdk.KEY_0, Gdk.KEY_KP_0): self._font_size = self._default_font_size self._apply_css() return True return False def set_font_size(self, size: int) -> None: """Updates the font size and reapplies CSS.""" self._font_size = size self._apply_css() def reapply_css( self, foreground: str | None = None, background: str | None = None, ) -> None: """Reapplies CSS styling (e.g. after color changes).""" if foreground is not None: self._foreground = foreground if background is not None: self._background = background self._apply_css() def _apply_css(self) -> None: """Apply CSS styling for background color, text color, font size, and border.""" bg = self._background fg = self._foreground close_px = max(16, self._font_size * 2) css = ( f".speech-monitor {{ background-color: {bg}; }}\n" f".speech-monitor-titlebar {{ min-height: 0; padding: 0; margin: 0; " f"background-color: {bg}; border: none; box-shadow: none; }}\n" f".speech-monitor decoration {{ box-shadow: none; " f"border: none; }}\n" f".speech-monitor-title {{ color: {fg}; font-weight: bold; }}\n" f".speech-monitor-close {{ min-height: {close_px}px; min-width: {close_px}px; " f"padding: 2px; margin: 0; color: {fg}; }}\n" f".speech-monitor-close:hover {{ opacity: 0.7; }}\n" f".speech-monitor-border {{ border: 3px solid {fg}; }}\n" f".speech-monitor-text {{ font-size: {self._font_size}pt; }}\n" f".speech-monitor-text text {{ color: {fg}; background-color: {bg}; }}" ) self._close_icon.set_pixel_size(close_px) if SpeechMonitor._shared_css_provider is not None: SpeechMonitor._shared_css_provider.load_from_data(css.encode()) def clear(self) -> None: """Clears the speech monitor display.""" self._buffer.set_text("") self._line_count = 0 def _append_text(self, text: str) -> None: """Appends text, trims excess lines, and scrolls to the bottom.""" self._buffer.insert(self._buffer.get_end_iter(), text) self._line_count += text.count("\n") if self._line_count > 500: excess = self._line_count - 500 start = self._buffer.get_start_iter() cut_point = self._buffer.get_iter_at_line(excess) self._buffer.delete(start, cut_point) self._line_count = 500 self._buffer.place_cursor(self._buffer.get_end_iter()) self._text_view.scroll_mark_onscreen(self._end_mark) def write_text(self, text: str) -> None: """Appends spoken text to the monitor.""" self._append_text(f"{text}\n") def write_key_event(self, key_description: str) -> None: """Appends a formatted key event entry to the monitor.""" self._append_text(f"[{key_description}]\n")