2025-08-19 08:06:37 -04:00

229 lines
6.5 KiB
Python

from talon import Module, actions, app, cron, registry, scope, settings, skia, ui
from talon.canvas import Canvas
from talon.screen import Screen
from talon.skia.canvas import Canvas as SkiaCanvas
from talon.skia.imagefilter import ImageFilter
from talon.ui import Point2d, Rect
canvas: Canvas = None
current_mode = ""
current_microphone = ""
mod = Module()
mod.setting(
"mode_indicator_show",
type=bool,
default=False,
desc="If true the mode indicator is shown",
)
mod.setting(
"mode_indicator_show_microphone_name",
type=bool,
default=False,
desc="Show first two letters of microphone name if true",
)
mod.setting(
"mode_indicator_size",
type=float,
desc="Mode indicator diameter in pixels",
)
mod.setting(
"mode_indicator_x",
type=float,
desc="Mode indicator center X-position in percentages(0-1). 0=left, 1=right",
)
mod.setting(
"mode_indicator_y",
type=float,
desc="Mode indicator center Y-position in percentages(0-1). 0=top, 1=bottom",
)
mod.setting(
"mode_indicator_color_alpha",
type=float,
desc="Mode indicator alpha/opacity in percentages(0-1). 0=fully transparent, 1=fully opaque",
)
mod.setting(
"mode_indicator_color_gradient",
type=float,
desc="Mode indicator gradient brightness in percentages(0-1). 0=darkest, 1=brightest",
)
mod.setting("mode_indicator_color_text", type=str)
mod.setting("mode_indicator_color_mute", type=str)
mod.setting("mode_indicator_color_sleep", type=str)
mod.setting("mode_indicator_color_dictation", type=str)
mod.setting("mode_indicator_color_mixed", type=str)
mod.setting("mode_indicator_color_command", type=str)
mod.setting("mode_indicator_color_other", type=str)
setting_paths = {
"user.mode_indicator_show",
"user.mode_indicator_size",
"user.mode_indicator_x",
"user.mode_indicator_y",
"user.mode_indicator_color_alpha",
"user.mode_indicator_color_gradient",
"user.mode_indicator_color_mute",
"user.mode_indicator_color_sleep",
"user.mode_indicator_color_dictation",
"user.mode_indicator_color_mixed",
"user.mode_indicator_color_command",
"user.mode_indicator_color_other",
}
def get_mode_color() -> str:
if current_microphone == "None":
return settings.get("user.mode_indicator_color_mute")
if current_mode == "sleep":
return settings.get("user.mode_indicator_color_sleep")
elif current_mode == "dictation":
return settings.get("user.mode_indicator_color_dictation")
elif current_mode == "mixed":
return settings.get("user.mode_indicator_color_mixed")
elif current_mode == "command":
return settings.get("user.mode_indicator_color_command")
else:
return settings.get("user.mode_indicator_color_other")
def get_alpha_color() -> str:
return f"{int(settings.get('user.mode_indicator_color_alpha') * 255):02x}"
def get_gradient_color(color: str) -> str:
factor = settings.get("user.mode_indicator_color_gradient")
# hex -> rgb
(r, g, b) = tuple(int(color[i : i + 2], 16) for i in (0, 2, 4))
# Darken rgb
r, g, b = int(r * factor), int(g * factor), int(b * factor)
# rgb -> hex
return f"{r:02x}{g:02x}{b:02x}"
def get_colors():
color_mode = get_mode_color()
color_gradient = get_gradient_color(color_mode)
color_alpha = get_alpha_color()
color_text = settings.get("user.mode_indicator_color_text")
return f"{color_mode}{color_alpha}", color_gradient, color_text
def on_draw(c: SkiaCanvas):
color_mode, color_gradient, color_text = get_colors()
x, y = c.rect.center.x, c.rect.center.y
radius = c.rect.height / 2 - 2
c.paint.shader = skia.Shader.radial_gradient(
Point2d(x, y), radius, [color_mode, color_gradient]
)
c.paint.imagefilter = ImageFilter.drop_shadow(1, 1, 1, 1, color_gradient)
c.paint.style = c.paint.Style.FILL
c.paint.color = color_mode
c.draw_circle(x, y, radius)
if settings.get("user.mode_indicator_show_microphone_name"):
# Remove c.paint.shader gradient before drawing again
c.paint.shader = skia.Shader.radial_gradient(
Point2d(x, y), radius, [color_text, color_text]
)
text = current_microphone[:2]
c.paint.style = c.paint.Style.FILL
c.paint.color = color_text
text_rect = c.paint.measure_text(text)[1]
c.draw_text(
text,
x - text_rect.center.x,
y - text_rect.center.y,
)
def move_indicator():
screen: Screen = ui.main_screen()
rect = screen.rect
scale = screen.scale if app.platform != "mac" else 1
radius = settings.get("user.mode_indicator_size") * scale / 2
x = rect.left + min(
max(settings.get("user.mode_indicator_x") * rect.width - radius, 0),
rect.width - 2 * radius,
)
y = rect.top + min(
max(settings.get("user.mode_indicator_y") * rect.height - radius, 0),
rect.height - 2 * radius,
)
side = 2 * radius
canvas.resize(side, side)
canvas.move(x, y)
def show_indicator():
global canvas
canvas = Canvas.from_rect(Rect(0, 0, 0, 0))
canvas.register("draw", on_draw)
def hide_indicator():
global canvas
canvas.unregister("draw", on_draw)
canvas.close()
canvas = None
def update_indicator():
if settings.get("user.mode_indicator_show"):
if not canvas:
show_indicator()
move_indicator()
canvas.freeze()
elif canvas:
hide_indicator()
def on_update_contexts():
global current_mode
modes = scope.get("mode")
if "sleep" in modes:
mode = "sleep"
elif "dictation" in modes:
if "command" in modes:
mode = "mixed"
else:
mode = "dictation"
elif "command" in modes:
mode = "command"
else:
mode = "other"
if current_mode != mode:
current_mode = mode
update_indicator()
def on_update_settings(updated_settings: set[str]):
if setting_paths & updated_settings:
update_indicator()
def poll_microphone():
# Ideally, we would have a callback instead of needing to poll. https://github.com/talonvoice/talon/issues/624
global current_microphone
microphone = actions.sound.active_microphone()
if current_microphone != microphone:
current_microphone = microphone
update_indicator()
def on_ready():
registry.register("update_contexts", on_update_contexts)
registry.register("update_settings", on_update_settings)
ui.register("screen_change", lambda _: update_indicator)
cron.interval("500ms", poll_microphone)
app.register("ready", on_ready)