init commit
@@ -0,0 +1,21 @@
|
||||
# plugin
|
||||
|
||||
The plugin folder has several other subfolders containing various commands:
|
||||
|
||||
- `cancel` contains commands to make talon ignore a command
|
||||
- `command_history` has commands to see previous commands
|
||||
- `datetimeinsert` has commands to automatically write the current date and time
|
||||
- `desktops` has commands to navigate between the different computer desktops
|
||||
- `draft_editor` has some of the commands to open and use a built-in pop-up text editor
|
||||
- `dropdown` has commands to select an option from a dropdown menu
|
||||
- `macro` has commands to use macros
|
||||
- `media` has commands for video and volume control
|
||||
- `microphone_selection` has commands for selecting a microphone to use
|
||||
- `mode_indicator` does not have commands, but has settings for enabling a graphical mode indicator
|
||||
- `mouse` has commands to click, drag, scroll, and use an eye tracker
|
||||
- `repeater` has commands for repeating other commands, described briefly in the top level [README](https://github.com/talonhub/community?tab=readme-ov-file#repeating-commands)
|
||||
- `screenshot` has commands for taking screenshots
|
||||
- `symbols` has commands for inserting certain symbols, like pairs of parentheses or quotation marks
|
||||
- `talon_draft_window` has the rest of the commands for using the draft editor window
|
||||
- `talon_helpers` has commands helpful for debugging, opening the talon directory, and getting updates
|
||||
- `text_navigation` has commands for navigating the cursor in text
|
||||
@@ -0,0 +1,28 @@
|
||||
# Are You Sure Dialog
|
||||
|
||||
This lets you require confirmation before executing an action, which can be useful for potentially destructive commands like shutting down your computer or exiting talon.
|
||||
|
||||
To require confirmation for an action, you use the user.are_you_sure_set_on_confirmation_action function that receives a message to display for the dialogue and the action to perform on confirmation. An optional action to perform on cancelling the action can be provided as the third argument. As this is intended to work with particularly destructive actions, this only supports executing a single action at a time and does not work with chaining.
|
||||
|
||||
You confirm an action by saying "yes I am sure" and cancel it by saying "cancel".
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from talon import actions, Module, app
|
||||
|
||||
mod = Module()
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def test_are_you_sure():
|
||||
'''A simple test for the are you sure dialog'''
|
||||
def on_confirm():
|
||||
app.notify('Confirmed')
|
||||
def on_cancel():
|
||||
app.notify('Cancelled')
|
||||
actions.user.are_you_sure_set_on_confirmation_action('Would you like to receive the on confirm message?', on_confirm, on_cancel)
|
||||
```
|
||||
|
||||
```talon
|
||||
test are you sure: user.test_are_you_sure()
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
from typing import Callable
|
||||
|
||||
from talon import Context, Module, actions, imgui
|
||||
|
||||
mod = Module()
|
||||
mod.tag("are_you_sure", desc="Activates are you sure commands")
|
||||
|
||||
|
||||
class ConfirmationState:
|
||||
def __init__(self):
|
||||
self.context = Context()
|
||||
|
||||
def request_confirmation(self, message: str, on_confirmation, on_disconfirmation):
|
||||
self.on_confirmation = on_confirmation
|
||||
self.on_cancel = on_disconfirmation
|
||||
self.message = message
|
||||
self.context.tags = ["user.are_you_sure"]
|
||||
gui.show()
|
||||
|
||||
def confirm(self):
|
||||
self.on_confirmation()
|
||||
self.cleanup()
|
||||
|
||||
def cancel(self):
|
||||
if self.on_cancel:
|
||||
self.on_cancel()
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
self.context.tags = []
|
||||
self.on_confirmation = None
|
||||
self.on_cancel = None
|
||||
self.message = None
|
||||
gui.hide()
|
||||
|
||||
def get_message(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
confirmation = ConfirmationState()
|
||||
|
||||
|
||||
@imgui.open(y=0)
|
||||
def gui(gui: imgui.GUI):
|
||||
gui.text(confirmation.get_message())
|
||||
gui.line()
|
||||
if gui.button("Yes I am sure"):
|
||||
actions.user.are_you_sure_confirm()
|
||||
if gui.button("Cancel"):
|
||||
actions.user.are_you_sure_cancel()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def are_you_sure_confirm():
|
||||
"""Performs the registered are you sure action"""
|
||||
confirmation.confirm()
|
||||
|
||||
def are_you_sure_cancel():
|
||||
"""Cancels the registered are you sure action"""
|
||||
confirmation.cancel()
|
||||
|
||||
def are_you_sure_set_on_confirmation_action(
|
||||
message: str, on_confirmation: Callable, on_cancel: Callable = None
|
||||
):
|
||||
"""Sets the action to be performed on user confirmation.
|
||||
message: the message to display to the user
|
||||
on_confirmation: the action to perform if the user confirms
|
||||
on_cancel: (optional) the action to perform if the user cancels
|
||||
This only supports working with a single action at a time and
|
||||
does not work with chaining as it is intended to be used with particularly destructive actions.
|
||||
"""
|
||||
confirmation.request_confirmation(message, on_confirmation, on_cancel)
|
||||
@@ -0,0 +1,4 @@
|
||||
tag: user.are_you_sure
|
||||
-
|
||||
yes I am sure: user.are_you_sure_confirm()
|
||||
cancel: user.are_you_sure_cancel()
|
||||
@@ -0,0 +1,73 @@
|
||||
# to disable command cancellation, comment out this entire file.
|
||||
# you may also wish to adjust the commands in misc/cancel.talon.
|
||||
|
||||
import time
|
||||
|
||||
from talon import Context, Module, actions, speech_system
|
||||
from talon.grammar import Phrase
|
||||
|
||||
# To change the phrase used to cancel commands, you must also adjust cancel.talon
|
||||
cancel_phrase = "cancel cancel".split()
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
|
||||
ts_threshold: float = 0
|
||||
|
||||
|
||||
@ctx.action_class("speech")
|
||||
class SpeechActions:
|
||||
# When Talon wakes we set the timestamp threshold. On the next command we
|
||||
# will compare the phrase timestamp to the threshold and cancel any phrase
|
||||
# started before wakeup. This is to prevent speech said before wake-up to
|
||||
# be interpreted as a command if the user wakes Talon using a noise or
|
||||
# keypress.
|
||||
def enable():
|
||||
actions.user.cancel_current_phrase()
|
||||
actions.next()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def cancel_current_phrase():
|
||||
"""Cancel/abort current spoken phrase"""
|
||||
global ts_threshold
|
||||
ts_threshold = time.perf_counter()
|
||||
|
||||
|
||||
def pre_phrase(phrase: Phrase):
|
||||
global ts_threshold
|
||||
|
||||
words = phrase["phrase"]
|
||||
|
||||
if not words:
|
||||
return
|
||||
|
||||
# Check if the phrase is before the threshold
|
||||
if ts_threshold != 0:
|
||||
# NB: mimic() and Dragon don't have this key.
|
||||
start = getattr(words[0], "start", None) or phrase.get("_ts", ts_threshold)
|
||||
phrase_starts_before_threshold = start < ts_threshold
|
||||
ts_threshold = 0
|
||||
# Start of phrase is before threshold timestamp
|
||||
if phrase_starts_before_threshold:
|
||||
print(f"Canceled phrase: {' '.join(words)}")
|
||||
cancel_entire_phrase(phrase)
|
||||
return
|
||||
|
||||
# Check if the phrase is a cancel command
|
||||
n = len(cancel_phrase)
|
||||
before, after = words[:-n], words[-n:]
|
||||
if after == cancel_phrase:
|
||||
actions.app.notify(f"Command canceled: {' '.join(before)!r}")
|
||||
cancel_entire_phrase(phrase)
|
||||
return
|
||||
|
||||
|
||||
def cancel_entire_phrase(phrase: Phrase):
|
||||
phrase["phrase"] = []
|
||||
if "parsed" in phrase:
|
||||
phrase["parsed"]._sequence = []
|
||||
|
||||
|
||||
speech_system.register("pre:phrase", pre_phrase)
|
||||
@@ -0,0 +1,6 @@
|
||||
# allows you to prevent a command executing by ending it with "cancel cancel"
|
||||
cancel cancel$: skip()
|
||||
# the actual behavior of "cancel cancel" is implemented in cancel.py; if you want to use a different phrase you must also change cancel_phrase there.
|
||||
|
||||
# allows you to say something (eg to a human) that you don't want talon to hear, eg "ignore hey Jerry"
|
||||
ignore [<phrase>]$: app.notify("Command ignored")
|
||||
@@ -0,0 +1,90 @@
|
||||
from typing import Optional
|
||||
|
||||
from talon import Module, actions, imgui, settings, speech_system
|
||||
|
||||
from ..subtitles.on_phrase import skip_phrase
|
||||
|
||||
# We keep command_history_size lines of history, but by default display only
|
||||
# command_history_display of them.
|
||||
mod = Module()
|
||||
mod.setting("command_history_size", type=int, default=50)
|
||||
mod.setting("command_history_display", type=int, default=10)
|
||||
|
||||
hist_more: bool = False
|
||||
history: list[str] = []
|
||||
|
||||
|
||||
def on_phrase(j):
|
||||
global history
|
||||
if skip_phrase(j):
|
||||
return
|
||||
|
||||
words = j.get("phrase")
|
||||
text = actions.user.history_transform_phrase_text(words)
|
||||
if text is not None:
|
||||
history.append(text)
|
||||
history = history[-settings.get("user.command_history_size") :]
|
||||
|
||||
|
||||
# todo: dynamic rect?
|
||||
@imgui.open(y=0)
|
||||
def gui(gui: imgui.GUI):
|
||||
global history
|
||||
gui.text("Command History")
|
||||
gui.line()
|
||||
text = (
|
||||
history[:]
|
||||
if hist_more
|
||||
else history[-settings.get("user.command_history_display") :]
|
||||
)
|
||||
for line in text:
|
||||
gui.text(line)
|
||||
|
||||
gui.spacer()
|
||||
if gui.button("Command history close"):
|
||||
actions.user.history_disable()
|
||||
|
||||
|
||||
speech_system.register("phrase", on_phrase)
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def history_toggle():
|
||||
"""Toggles viewing the history"""
|
||||
if gui.showing:
|
||||
gui.hide()
|
||||
else:
|
||||
gui.show()
|
||||
|
||||
def history_enable():
|
||||
"""Enables the history"""
|
||||
gui.show()
|
||||
|
||||
def history_disable():
|
||||
"""Disables the history"""
|
||||
gui.hide()
|
||||
|
||||
def history_clear():
|
||||
"""Clear the history"""
|
||||
global history
|
||||
history = []
|
||||
|
||||
def history_more():
|
||||
"""Show more history"""
|
||||
global hist_more
|
||||
hist_more = True
|
||||
|
||||
def history_less():
|
||||
"""Show less history"""
|
||||
global hist_more
|
||||
hist_more = False
|
||||
|
||||
def history_get(number: int) -> str:
|
||||
"""returns the history entry at the specified index"""
|
||||
num = (0 - number) - 1
|
||||
return history[num]
|
||||
|
||||
def history_transform_phrase_text(words: list[str]) -> Optional[str]:
|
||||
"""Transforms phrase text for presentation in history. Return `None` to omit from history"""
|
||||
return " ".join(words) if words else None
|
||||
@@ -0,0 +1,5 @@
|
||||
command history: user.history_toggle()
|
||||
command history close: user.history_disable()
|
||||
command history clear: user.history_clear()
|
||||
command history less: user.history_less()
|
||||
command history more: user.history_more()
|
||||
@@ -0,0 +1,24 @@
|
||||
import datetime
|
||||
|
||||
from talon import Module
|
||||
|
||||
mod = Module()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def time_format(fmt: str = None) -> str:
|
||||
"""Return the current time, formatted.
|
||||
fmt: strftime()-style format string, defaults to ISO format."""
|
||||
now = datetime.datetime.now()
|
||||
if fmt is None:
|
||||
return now.isoformat()
|
||||
return now.strftime(fmt)
|
||||
|
||||
def time_format_utc(fmt: str = None) -> str:
|
||||
"""Return the current UTC time, formatted.
|
||||
fmt: strftime()-style format string, defaults to ISO format."""
|
||||
now = datetime.datetime.utcnow()
|
||||
if fmt is None:
|
||||
return now.isoformat()
|
||||
return now.strftime(fmt)
|
||||
@@ -0,0 +1,7 @@
|
||||
date insert: insert(user.time_format("%Y-%m-%d"))
|
||||
date insert UTC: insert(user.time_format_utc("%Y-%m-%d"))
|
||||
timestamp insert: insert(user.time_format("%Y-%m-%d %H:%M:%S"))
|
||||
timestamp insert high resolution: insert(user.time_format("%Y-%m-%d %H:%M:%S.%f"))
|
||||
timestamp insert UTC: insert(user.time_format_utc("%Y-%m-%d %H:%M:%S"))
|
||||
timestamp insert UTC high resolution:
|
||||
insert(user.time_format_utc("%Y-%m-%d %H:%M:%S.%f"))
|
||||
@@ -0,0 +1,34 @@
|
||||
from talon import Module, app
|
||||
|
||||
mod = Module()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def desktop(number: int):
|
||||
"""change the current desktop"""
|
||||
app.notify("Not supported on this operating system")
|
||||
|
||||
def desktop_show():
|
||||
"""shows the current desktops"""
|
||||
app.notify("Not supported on this operating system")
|
||||
|
||||
def desktop_next():
|
||||
"""move to next desktop"""
|
||||
app.notify("Not supported on this operating system")
|
||||
|
||||
def desktop_last():
|
||||
"""move to previous desktop"""
|
||||
app.notify("Not supported on this operating system")
|
||||
|
||||
def window_move_desktop_left():
|
||||
"""move the current window to the desktop to the left"""
|
||||
app.notify("Not supported on this operating system")
|
||||
|
||||
def window_move_desktop_right():
|
||||
"""move the current window to the desktop to the right"""
|
||||
app.notify("Not supported on this operating system")
|
||||
|
||||
def window_move_desktop(desktop_number: int):
|
||||
"""move the current window to a different desktop"""
|
||||
app.notify("Not supported on this operating system")
|
||||
@@ -0,0 +1,7 @@
|
||||
desk <number_small>: user.desktop(number_small)
|
||||
desk next: user.desktop_next()
|
||||
desk last: user.desktop_last()
|
||||
desk show: user.desktop_show()
|
||||
window move desk <number>: user.window_move_desktop(number)
|
||||
window move desk left: user.window_move_desktop_left()
|
||||
window move desk right: user.window_move_desktop_right()
|
||||
@@ -0,0 +1,31 @@
|
||||
from talon import Context, actions, ui
|
||||
|
||||
ctx = Context()
|
||||
ctx.matches = r"""
|
||||
os: linux
|
||||
"""
|
||||
|
||||
|
||||
@ctx.action_class("user")
|
||||
class Actions:
|
||||
def desktop(number: int):
|
||||
ui.switch_workspace(number)
|
||||
|
||||
def desktop_next():
|
||||
actions.user.desktop(ui.active_workspace() + 1)
|
||||
|
||||
def desktop_last():
|
||||
actions.user.desktop(ui.active_workspace() - 1)
|
||||
|
||||
def desktop_show():
|
||||
actions.key("super")
|
||||
|
||||
def window_move_desktop(desktop_number: int):
|
||||
ui.active_window().workspace = desktop_number
|
||||
actions.user.desktop(desktop_number)
|
||||
|
||||
def window_move_desktop_left():
|
||||
actions.user.window_move_desktop(ui.active_workspace() - 1)
|
||||
|
||||
def window_move_desktop_right():
|
||||
actions.user.window_move_desktop(ui.active_workspace() + 1)
|
||||
@@ -0,0 +1,58 @@
|
||||
import contextlib
|
||||
import time
|
||||
|
||||
from talon import Context, actions, ctrl, ui
|
||||
|
||||
ctx = Context()
|
||||
ctx.matches = r"""
|
||||
os: mac
|
||||
"""
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _drag_window_mac(win=None):
|
||||
if win is None:
|
||||
win = ui.active_window()
|
||||
fs = win.children.find(AXSubrole="AXFullScreenButton")[0]
|
||||
rect = fs.AXFrame
|
||||
x = rect.x + rect.width + 5
|
||||
y = rect.y + rect.height / 2
|
||||
previous_position = ctrl.mouse_pos()
|
||||
ctrl.mouse_move(x, y)
|
||||
ctrl.mouse_click(button=0, down=True)
|
||||
yield
|
||||
time.sleep(0.1)
|
||||
ctrl.mouse_click(button=0, up=True)
|
||||
ctrl.mouse_move(*previous_position)
|
||||
|
||||
|
||||
@ctx.action_class("user")
|
||||
class MacActions:
|
||||
def desktop(number: int):
|
||||
if number < 10:
|
||||
actions.key(f"ctrl-{number}")
|
||||
|
||||
def desktop_next():
|
||||
actions.key("ctrl-right")
|
||||
|
||||
def desktop_last():
|
||||
actions.key("ctrl-left")
|
||||
|
||||
def desktop_show():
|
||||
actions.key("ctrl-up")
|
||||
|
||||
def window_move_desktop_left():
|
||||
with _drag_window_mac():
|
||||
actions.user.desktop_last()
|
||||
|
||||
def window_move_desktop_right():
|
||||
with _drag_window_mac():
|
||||
actions.user.desktop_next()
|
||||
|
||||
def window_move_desktop(desktop_number: int):
|
||||
# TODO: amethyst stuff should be pulled out into a separate file
|
||||
if ui.apps(bundle="com.amethyst.Amethyst"):
|
||||
actions.key(f"ctrl-alt-shift-{desktop_number}")
|
||||
else:
|
||||
with _drag_window_mac():
|
||||
actions.user.desktop(desktop_number)
|
||||
@@ -0,0 +1,26 @@
|
||||
from talon import Context, actions
|
||||
|
||||
ctx = Context()
|
||||
ctx.matches = r"""
|
||||
os: windows
|
||||
"""
|
||||
|
||||
|
||||
@ctx.action_class("user")
|
||||
class Actions:
|
||||
# def desktop(number: int):
|
||||
|
||||
def desktop_next():
|
||||
actions.key("super-ctrl-right")
|
||||
|
||||
def desktop_last():
|
||||
actions.key("super-ctrl-left")
|
||||
|
||||
def desktop_show():
|
||||
actions.key("super-tab")
|
||||
|
||||
# def window_move_desktop_left():
|
||||
|
||||
# def window_move_desktop_right():
|
||||
|
||||
# def window_move_desktop(desktop_number: int):
|
||||
@@ -0,0 +1,149 @@
|
||||
from talon import Context, Module, actions, app, settings, ui
|
||||
|
||||
mod = Module()
|
||||
mod.tag("draft_editor_active", "Indicates whether the draft editor has been activated")
|
||||
mod.tag(
|
||||
"draft_editor_app_running",
|
||||
"Indicates that the draft editor app currently is running",
|
||||
)
|
||||
mod.tag(
|
||||
"draft_editor_app_focused",
|
||||
"Indicates that the draft editor app currently has focus",
|
||||
)
|
||||
|
||||
ctx = Context()
|
||||
tags: set[str] = set()
|
||||
|
||||
|
||||
def add_tag(tag: str):
|
||||
if tag not in tags:
|
||||
tags.add(tag)
|
||||
ctx.tags = list(tags)
|
||||
|
||||
|
||||
def remove_tag(tag: str):
|
||||
if tag in tags:
|
||||
tags.discard(tag)
|
||||
ctx.tags = list(tags)
|
||||
|
||||
|
||||
default_names = ["Visual Studio Code", "Code", "VSCodium", "Codium", "code-oss"]
|
||||
|
||||
mod.setting(
|
||||
"draft_editor",
|
||||
type=str,
|
||||
default=None,
|
||||
desc="List of application names to use for draft editor",
|
||||
)
|
||||
|
||||
|
||||
def get_editor_names():
|
||||
names_csv = settings.get("user.draft_editor")
|
||||
return names_csv.split(", ") if names_csv else default_names
|
||||
|
||||
|
||||
def handle_app_running(_app):
|
||||
editor_names = get_editor_names()
|
||||
for app in ui.apps(background=False):
|
||||
if app.name in editor_names:
|
||||
add_tag("user.draft_editor_app_running")
|
||||
return
|
||||
remove_tag("user.draft_editor_app_running")
|
||||
|
||||
|
||||
def handle_app_activate(app):
|
||||
if app.name in get_editor_names():
|
||||
add_tag("user.draft_editor_app_focused")
|
||||
else:
|
||||
remove_tag("user.draft_editor_app_focused")
|
||||
|
||||
|
||||
def on_ready():
|
||||
ui.register("app_launch", handle_app_running)
|
||||
ui.register("app_close", handle_app_running)
|
||||
ui.register("app_activate", handle_app_activate)
|
||||
|
||||
handle_app_running(None)
|
||||
handle_app_activate(ui.active_app())
|
||||
|
||||
|
||||
app.register("ready", on_ready)
|
||||
|
||||
original_window = None
|
||||
|
||||
last_draft = None
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def draft_editor_open():
|
||||
"""Open draft editor"""
|
||||
global original_window
|
||||
original_window = ui.active_window()
|
||||
editor_app = get_editor_app()
|
||||
selected_text = actions.edit.selected_text()
|
||||
actions.user.switcher_focus_app(editor_app)
|
||||
# Wait additional time for talon context to update.
|
||||
actions.sleep("200ms")
|
||||
actions.app.tab_open()
|
||||
if selected_text != "":
|
||||
actions.user.paste(selected_text)
|
||||
add_tag("user.draft_editor_active")
|
||||
|
||||
def draft_editor_submit():
|
||||
"""Submit/save draft editor"""
|
||||
close_editor(submit_draft=True)
|
||||
|
||||
def draft_editor_discard():
|
||||
"""Discard draft editor"""
|
||||
close_editor(submit_draft=False)
|
||||
|
||||
def draft_editor_paste_last():
|
||||
"""Paste last submitted draft"""
|
||||
if last_draft:
|
||||
actions.user.paste(last_draft)
|
||||
|
||||
|
||||
def get_editor_app() -> ui.App:
|
||||
editor_names = get_editor_names()
|
||||
|
||||
for app in ui.apps(background=False):
|
||||
if app.name in editor_names:
|
||||
return app
|
||||
|
||||
raise RuntimeError("Draft editor is not running")
|
||||
|
||||
|
||||
def close_editor(submit_draft: bool) -> None:
|
||||
global last_draft
|
||||
|
||||
actions.edit.select_all()
|
||||
|
||||
if submit_draft:
|
||||
actions.sleep("50ms")
|
||||
last_draft = actions.edit.selected_text()
|
||||
|
||||
if not last_draft:
|
||||
actions.app.notify("Failed to get draft document text")
|
||||
return
|
||||
|
||||
remove_tag("user.draft_editor_active")
|
||||
|
||||
actions.edit.delete()
|
||||
actions.app.tab_close()
|
||||
|
||||
if submit_draft:
|
||||
try:
|
||||
actions.user.switcher_focus_window(original_window)
|
||||
except Exception:
|
||||
app.notify(
|
||||
"Failed to focus on window to submit draft, manually focus intended destination and use 'draft submit' again"
|
||||
)
|
||||
else:
|
||||
actions.sleep("300ms")
|
||||
actions.user.paste(last_draft)
|
||||
else:
|
||||
try:
|
||||
actions.user.switcher_focus_window(original_window)
|
||||
except Exception:
|
||||
app.notify("Failed to focus previous window, leaving editor open")
|
||||
@@ -0,0 +1,23 @@
|
||||
tag: user.draft_editor_app_running
|
||||
and not tag: user.draft_editor_app_focused
|
||||
-
|
||||
|
||||
draft this: user.draft_editor_open()
|
||||
|
||||
draft all:
|
||||
edit.select_all()
|
||||
user.draft_editor_open()
|
||||
|
||||
draft line:
|
||||
edit.select_line()
|
||||
user.draft_editor_open()
|
||||
|
||||
draft top:
|
||||
edit.extend_file_start()
|
||||
user.draft_editor_open()
|
||||
|
||||
draft bottom:
|
||||
edit.extend_file_end()
|
||||
user.draft_editor_open()
|
||||
|
||||
draft submit: user.draft_editor_paste_last()
|
||||
@@ -0,0 +1,6 @@
|
||||
tag: user.draft_editor_active
|
||||
and tag: user.draft_editor_app_focused
|
||||
-
|
||||
|
||||
draft submit: user.draft_editor_submit()
|
||||
draft discard: user.draft_editor_discard()
|
||||
@@ -0,0 +1,4 @@
|
||||
# DEPRECATED
|
||||
drop down <number_small>: user.deprecate_command("2024-05-29", "drop down", "choose")
|
||||
drop down up <number_small>:
|
||||
user.deprecate_command("2024-05-29", "drop down up", "choose up")
|
||||
@@ -0,0 +1,10 @@
|
||||
# from talon import app
|
||||
# from talon.types import Point2d
|
||||
# from talon_plugins import eye_mouse, eye_zoom_mouse
|
||||
|
||||
# if app.platform == "mac":
|
||||
# eye_zoom_mouse.config.screen_area = Point2d(100, 75)
|
||||
# eye_zoom_mouse.config.img_scale = 6
|
||||
# elif app.platform == "windows":
|
||||
# eye_zoom_mouse.config.screen_area = Point2d(200, 150)
|
||||
# eye_zoom_mouse.config.img_scale = 4.5
|
||||
@@ -0,0 +1,29 @@
|
||||
# Gamepad
|
||||
|
||||
Some predefined gamepad bindings for doing tasks like clicking, scrolling and moving your cursor.
|
||||
|
||||
### Usage
|
||||
|
||||
To enable the gamepad bindings activate tag `user.gamepad` in [gamepad_settings.talon](./gamepad_settings.talon)
|
||||
|
||||
## Demo - Using gamepad
|
||||
|
||||
[YouTube - Gamepad demo](https://youtu.be/zNeiZ9nnK_A)
|
||||
|
||||
## Gamepad tester
|
||||
|
||||

|
||||
|
||||
### Usage
|
||||
|
||||
1. Say `"gamepad tester"` to open gamepad tester UI.
|
||||
1. Press buttons on actual gamepad and see interaction in UI.
|
||||
1. Close gamepad tester by saying `"gamepad tester"` again.
|
||||
|
||||
### Demo - Gamepad tester
|
||||
|
||||
[YouTube - Gamepad tester demo](https://youtu.be/FzfIlaHm8_w)
|
||||
|
||||
### Conflict with existing gamepad implementations
|
||||
|
||||
The gamepad tester doesn't disable your existing gamepad implementations. If you don't want your existing gamepad implementations to trigger during the testing phase you can add `not tag: user.gamepad_tester` at the top of your gamepad Talon files.
|
||||
@@ -0,0 +1,304 @@
|
||||
from talon import Module, actions, ctrl, ui
|
||||
from talon.screen import Screen
|
||||
|
||||
screen: Screen = ui.main_screen()
|
||||
slow_scroll = False
|
||||
slow_mouse_move = False
|
||||
|
||||
mod = Module()
|
||||
mod.tag("gamepad", desc="Activate tag to enable gamepad bindings")
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
# DPAD buttons
|
||||
|
||||
def gamepad_press_dpad_left():
|
||||
"""Gamepad press button dpad left"""
|
||||
gamepad_mouse_jump("left")
|
||||
|
||||
def gamepad_release_dpad_left():
|
||||
"""Gamepad release button dpad left"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_press_dpad_up():
|
||||
"""Gamepad press button dpad up"""
|
||||
gamepad_mouse_jump("up")
|
||||
|
||||
def gamepad_release_dpad_up():
|
||||
"""Gamepad release button dpad up"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_press_dpad_right():
|
||||
"""Gamepad press button dpad right"""
|
||||
gamepad_mouse_jump("right")
|
||||
|
||||
def gamepad_release_dpad_right():
|
||||
"""Gamepad release button dpad right"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_press_dpad_down():
|
||||
"""Gamepad press button dpad down"""
|
||||
gamepad_mouse_jump("down")
|
||||
|
||||
def gamepad_release_dpad_down():
|
||||
"""Gamepad release button dpad down"""
|
||||
actions.skip()
|
||||
|
||||
# Compass / ABXY buttons
|
||||
|
||||
def gamepad_press_west():
|
||||
"""Gamepad press button west"""
|
||||
actions.mouse_drag(0)
|
||||
|
||||
def gamepad_release_west():
|
||||
"""Gamepad release button west"""
|
||||
actions.mouse_release(0)
|
||||
|
||||
def gamepad_press_north():
|
||||
"""Gamepad press button north"""
|
||||
actions.mouse_drag(1)
|
||||
|
||||
def gamepad_release_north():
|
||||
"""Gamepad release button north"""
|
||||
actions.mouse_release(1)
|
||||
|
||||
def gamepad_press_east():
|
||||
"""Gamepad press button east"""
|
||||
actions.key("ctrl:down")
|
||||
actions.mouse_click()
|
||||
actions.key("ctrl:up")
|
||||
|
||||
def gamepad_release_east():
|
||||
"""Gamepad release button east"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_press_south():
|
||||
"""Gamepad press button south"""
|
||||
actions.mouse_drag(2)
|
||||
|
||||
def gamepad_release_south():
|
||||
"""Gamepad release button south"""
|
||||
actions.mouse_release(2)
|
||||
|
||||
# Select / Start buttons
|
||||
|
||||
def gamepad_press_select():
|
||||
"""Gamepad press button select"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_release_select():
|
||||
"""Gamepad release button select"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_press_start():
|
||||
"""Gamepad press button start"""
|
||||
actions.speech.toggle()
|
||||
|
||||
def gamepad_release_start():
|
||||
"""Gamepad release button start"""
|
||||
actions.skip()
|
||||
|
||||
# Shoulder buttons
|
||||
|
||||
def gamepad_press_left_shoulder():
|
||||
"""Gamepad press button left shoulder"""
|
||||
actions.user.go_back()
|
||||
|
||||
def gamepad_release_left_shoulder():
|
||||
"""Gamepad release button left shoulder"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_press_right_shoulder():
|
||||
"""Gamepad press button right shoulder"""
|
||||
actions.user.go_forward()
|
||||
|
||||
def gamepad_release_right_shoulder():
|
||||
"""Gamepad release button right shoulder"""
|
||||
actions.skip()
|
||||
|
||||
# Stick buttons
|
||||
|
||||
def gamepad_press_left_stick():
|
||||
"""Gamepad press button left thumb stick"""
|
||||
gamepad_scroll_slow_toggle()
|
||||
|
||||
def gamepad_release_left_stick():
|
||||
"""Gamepad release button left thumb stick"""
|
||||
actions.skip()
|
||||
|
||||
def gamepad_press_right_stick():
|
||||
"""Gamepad press button right thumb stick"""
|
||||
gamepad_mouse_move_slow_toggle()
|
||||
|
||||
def gamepad_release_right_stick():
|
||||
"""Gamepad release button right thumb stick"""
|
||||
actions.skip()
|
||||
|
||||
# Analog triggers
|
||||
|
||||
def gamepad_trigger_left(value: float):
|
||||
"""Gamepad trigger left movement"""
|
||||
gamepad_scroll(0, value * -1)
|
||||
|
||||
def gamepad_trigger_right(value: float):
|
||||
"""Gamepad trigger right movement"""
|
||||
gamepad_scroll(0, value)
|
||||
|
||||
# Analog thumb sticks
|
||||
|
||||
def gamepad_stick_left(x: float, y: float):
|
||||
"""Gamepad left stick movement"""
|
||||
gamepad_scroll(x, y)
|
||||
|
||||
def gamepad_stick_right(x: float, y: float):
|
||||
"""Gamepad right stick movement"""
|
||||
gamepad_mouse_move(x, y)
|
||||
|
||||
# Scaffolding actions used by the Talon file
|
||||
|
||||
def gamepad_button_down(button: str):
|
||||
"""Gamepad press button <button>"""
|
||||
match button:
|
||||
# DPAD buttons
|
||||
case "dpad_left":
|
||||
actions.user.gamepad_press_dpad_left()
|
||||
case "dpad_up":
|
||||
actions.user.gamepad_press_dpad_up()
|
||||
case "dpad_right":
|
||||
actions.user.gamepad_press_dpad_right()
|
||||
case "dpad_down":
|
||||
actions.user.gamepad_press_dpad_down()
|
||||
|
||||
# Compass / ABXY buttons
|
||||
case "west":
|
||||
actions.user.gamepad_press_west()
|
||||
case "north":
|
||||
actions.user.gamepad_press_north()
|
||||
case "east":
|
||||
actions.user.gamepad_press_east()
|
||||
case "south":
|
||||
actions.user.gamepad_press_south()
|
||||
|
||||
# Select / Start buttons
|
||||
case "select":
|
||||
actions.user.gamepad_press_select()
|
||||
case "start":
|
||||
actions.user.gamepad_press_start()
|
||||
|
||||
# Shoulder buttons
|
||||
case "left_shoulder":
|
||||
actions.user.gamepad_press_left_shoulder()
|
||||
case "right_shoulder":
|
||||
actions.user.gamepad_press_right_shoulder()
|
||||
|
||||
# Stick buttons
|
||||
case "left_stick":
|
||||
actions.user.gamepad_press_left_stick()
|
||||
case "right_stick":
|
||||
actions.user.gamepad_press_right_stick()
|
||||
|
||||
case _:
|
||||
raise ValueError(f"Unknown button: {button}")
|
||||
|
||||
def gamepad_button_up(button: str):
|
||||
"""Gamepad release button <button>"""
|
||||
match button:
|
||||
# DPAD buttons
|
||||
case "dpad_left":
|
||||
actions.user.gamepad_release_dpad_left()
|
||||
case "dpad_up":
|
||||
actions.user.gamepad_release_dpad_up()
|
||||
case "dpad_right":
|
||||
actions.user.gamepad_release_dpad_right()
|
||||
case "dpad_down":
|
||||
actions.user.gamepad_release_dpad_down()
|
||||
|
||||
# Compass / ABXY buttons
|
||||
case "west":
|
||||
actions.user.gamepad_release_west()
|
||||
case "north":
|
||||
actions.user.gamepad_release_north()
|
||||
case "east":
|
||||
actions.user.gamepad_release_east()
|
||||
case "south":
|
||||
actions.user.gamepad_release_south()
|
||||
|
||||
# Select / Start buttons
|
||||
case "select":
|
||||
actions.user.gamepad_release_select()
|
||||
case "start":
|
||||
actions.user.gamepad_release_start()
|
||||
|
||||
# Shoulder buttons
|
||||
case "left_shoulder":
|
||||
actions.user.gamepad_release_left_shoulder()
|
||||
case "right_shoulder":
|
||||
actions.user.gamepad_release_right_shoulder()
|
||||
|
||||
# Stick buttons
|
||||
case "left_stick":
|
||||
actions.user.gamepad_release_left_stick()
|
||||
case "right_stick":
|
||||
actions.user.gamepad_release_right_stick()
|
||||
|
||||
case _:
|
||||
raise ValueError(f"Unknown button: {button}")
|
||||
|
||||
|
||||
def gamepad_scroll(x: float, y: float):
|
||||
"""Perform gamepad scrolling"""
|
||||
multiplier = 1.5 if slow_scroll else 3
|
||||
x = x**3 * multiplier
|
||||
y = y**3 * multiplier
|
||||
|
||||
if x != 0 or y != 0:
|
||||
actions.mouse_scroll(x=x, y=y, by_lines=True)
|
||||
|
||||
|
||||
def gamepad_mouse_move(dx: float, dy: float):
|
||||
"""Perform gamepad mouse cursor movement"""
|
||||
multiplier = 0.1 if slow_mouse_move else 0.2
|
||||
x, y = ctrl.mouse_pos()
|
||||
screen = get_screen(x, y)
|
||||
dx = dx**3 * screen.dpi * multiplier
|
||||
dy = dy**3 * screen.dpi * multiplier
|
||||
actions.mouse_move(x + dx, y + dy)
|
||||
|
||||
|
||||
def gamepad_scroll_slow_toggle():
|
||||
"""Toggle gamepad slow scroll mode"""
|
||||
global slow_scroll
|
||||
slow_scroll = not slow_scroll
|
||||
|
||||
|
||||
def gamepad_mouse_move_slow_toggle():
|
||||
"""Toggle gamepad slow mouse move mode"""
|
||||
global slow_mouse_move
|
||||
slow_mouse_move = not slow_mouse_move
|
||||
|
||||
|
||||
def gamepad_mouse_jump(direction: str):
|
||||
"""Move the mouse cursor to the specified quadrant of the active screen"""
|
||||
x, y = ctrl.mouse_pos()
|
||||
rect = ui.screen_containing(x, y).rect
|
||||
|
||||
# Half distance between cursor and screen edge
|
||||
match direction:
|
||||
case "up":
|
||||
y = rect.top + (y - rect.top) / 2
|
||||
case "down":
|
||||
y = rect.bot - (rect.bot - y) / 2
|
||||
case "left":
|
||||
x = rect.left + (x - rect.left) / 2
|
||||
case "right":
|
||||
x = rect.right - (rect.right - x) / 2
|
||||
|
||||
actions.mouse_move(x, y)
|
||||
|
||||
|
||||
def get_screen(x: float, y: float) -> Screen:
|
||||
global screen
|
||||
if not screen.contains(x, y):
|
||||
screen = ui.screen_containing(x, y)
|
||||
return screen
|
||||
@@ -0,0 +1,49 @@
|
||||
tag: user.gamepad
|
||||
and not tag: user.gamepad_tester
|
||||
-
|
||||
|
||||
# DPAD buttons
|
||||
gamepad(dpad_left:down): user.gamepad_button_down("dpad_left")
|
||||
gamepad(dpad_left:up): user.gamepad_button_up("dpad_left")
|
||||
gamepad(dpad_up:down): user.gamepad_button_down("dpad_up")
|
||||
gamepad(dpad_up:up): user.gamepad_button_up("dpad_up")
|
||||
gamepad(dpad_right:down): user.gamepad_button_down("dpad_right")
|
||||
gamepad(dpad_right:up): user.gamepad_button_up("dpad_right")
|
||||
gamepad(dpad_down:down): user.gamepad_button_down("dpad_down")
|
||||
gamepad(dpad_down:up): user.gamepad_button_up("dpad_down")
|
||||
|
||||
# Compass / ABXY buttons
|
||||
gamepad(west:down): user.gamepad_button_down("west")
|
||||
gamepad(west:up): user.gamepad_button_up("west")
|
||||
gamepad(north:down): user.gamepad_button_down("north")
|
||||
gamepad(north:up): user.gamepad_button_up("north")
|
||||
gamepad(east:down): user.gamepad_button_down("east")
|
||||
gamepad(east:up): user.gamepad_button_up("east")
|
||||
gamepad(south:down): user.gamepad_button_down("south")
|
||||
gamepad(south:up): user.gamepad_button_up("south")
|
||||
|
||||
# Select / Start buttons
|
||||
gamepad(select:down): user.gamepad_button_down("select")
|
||||
gamepad(select:up): user.gamepad_button_up("select")
|
||||
gamepad(start:down): user.gamepad_button_down("start")
|
||||
gamepad(start:up): user.gamepad_button_up("start")
|
||||
|
||||
# Shoulder buttons
|
||||
gamepad(l1:down): user.gamepad_button_down("left_shoulder")
|
||||
gamepad(l1:up): user.gamepad_button_up("left_shoulder")
|
||||
gamepad(r1:down): user.gamepad_button_down("right_shoulder")
|
||||
gamepad(r1:up): user.gamepad_button_up("right_shoulder")
|
||||
|
||||
# Stick buttons
|
||||
gamepad(l3:down): user.gamepad_button_down("left_stick")
|
||||
gamepad(l3:up): user.gamepad_button_up("left_stick")
|
||||
gamepad(r3:down): user.gamepad_button_down("right_stick")
|
||||
gamepad(r3:up): user.gamepad_button_up("right_stick")
|
||||
|
||||
# Analog triggers
|
||||
gamepad(l2:repeat): user.gamepad_trigger_left(value)
|
||||
gamepad(r2:repeat): user.gamepad_trigger_right(value)
|
||||
|
||||
# Analog thumb sticks
|
||||
gamepad(left_xy:repeat): user.gamepad_stick_left(x, y*-1)
|
||||
gamepad(right_xy:repeat): user.gamepad_stick_right(x, y*-1)
|
||||
@@ -0,0 +1,2 @@
|
||||
# Uncomment to enable gamepad support
|
||||
# tag(): user.gamepad
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,290 @@
|
||||
from talon import Context, Module, ui
|
||||
from talon.canvas import Canvas, MouseEvent
|
||||
from talon.screen import Screen
|
||||
from talon.skia.canvas import Canvas as SkiaCanvas
|
||||
from talon.types import Point2d, Rect
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
canvas: Canvas | None = None
|
||||
last_mouse_pos: Point2d | None = None
|
||||
|
||||
mod.tag("gamepad_tester", "Gamepad tester gui is showing")
|
||||
|
||||
buttons = {
|
||||
"dpad_up": False,
|
||||
"dpad_right": False,
|
||||
"dpad_down": False,
|
||||
"dpad_left": False,
|
||||
"north": False,
|
||||
"east": False,
|
||||
"south": False,
|
||||
"west": False,
|
||||
"select": False,
|
||||
"start": False,
|
||||
"l1": False,
|
||||
"r1": False,
|
||||
"l3": False,
|
||||
"r3": False,
|
||||
}
|
||||
|
||||
triggers = {
|
||||
"l2": 0.0,
|
||||
"r2": 0.0,
|
||||
}
|
||||
|
||||
sticks = {
|
||||
"left": (0.0, 0.0),
|
||||
"right": (0.0, 0.0),
|
||||
}
|
||||
|
||||
BACKGROUND_COLOR = "fffafa" # Snow
|
||||
BORDER_COLOR = "000000" # Black
|
||||
FONT_SIZE = 16
|
||||
WIDTH = 900
|
||||
HEIGHT = 800
|
||||
CIRCLE_RADIUS = 100
|
||||
BUTTON_OFFSET = CIRCLE_RADIUS / 2
|
||||
BUTTON_RADIUS = BUTTON_OFFSET / 2
|
||||
ROW_OFFSET = CIRCLE_RADIUS * 1.25
|
||||
BUTTON_FLAT_WIDTH = BUTTON_RADIUS * 3
|
||||
BUTTON_FLAT_HEIGHT = BUTTON_RADIUS
|
||||
TRIGGER_HEIGHT = CIRCLE_RADIUS
|
||||
BUTTON_OFFSETS = [(0, -1), (1, 0), (0, 1), (-1, 0)]
|
||||
|
||||
|
||||
def render_round_button(c: SkiaCanvas, x: float, y: float, is_pressed: bool):
|
||||
c.paint.style = c.paint.Style.FILL if is_pressed else c.paint.Style.STROKE
|
||||
c.draw_circle(x, y, BUTTON_RADIUS)
|
||||
|
||||
|
||||
def render_square_button(c: SkiaCanvas, x: float, y: float, is_pressed: bool):
|
||||
c.paint.style = c.paint.Style.FILL if is_pressed else c.paint.Style.STROKE
|
||||
c.draw_rect(
|
||||
Rect(
|
||||
x - BUTTON_RADIUS,
|
||||
y - BUTTON_RADIUS,
|
||||
BUTTON_RADIUS * 2,
|
||||
BUTTON_RADIUS * 2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def render_flat_button(c: SkiaCanvas, x: float, y: float, is_pressed: bool):
|
||||
c.paint.style = c.paint.Style.FILL if is_pressed else c.paint.Style.STROKE
|
||||
c.draw_rect(
|
||||
Rect(
|
||||
x - BUTTON_FLAT_WIDTH / 2,
|
||||
y - BUTTON_FLAT_HEIGHT / 2,
|
||||
BUTTON_FLAT_WIDTH,
|
||||
BUTTON_FLAT_HEIGHT,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def render_buttons(
|
||||
c: SkiaCanvas, x: float, y: float, useCircle: bool, buttons_ids: list[str]
|
||||
):
|
||||
c.paint.style = c.paint.Style.STROKE
|
||||
c.draw_circle(x, y, CIRCLE_RADIUS)
|
||||
for i, button_id in enumerate(buttons_ids):
|
||||
(offset_x, offset_y) = BUTTON_OFFSETS[i]
|
||||
button_x = x + offset_x * BUTTON_OFFSET
|
||||
button_y = y + offset_y * BUTTON_OFFSET
|
||||
is_pressed = buttons[button_id]
|
||||
if useCircle:
|
||||
render_round_button(c, button_x, button_y, is_pressed)
|
||||
else:
|
||||
render_square_button(c, button_x, button_y, is_pressed)
|
||||
|
||||
|
||||
def render_trigger(c: SkiaCanvas, x: float, y: float, value: float):
|
||||
# Render button outline
|
||||
c.paint.style = c.paint.Style.STROKE
|
||||
c.draw_rect(
|
||||
Rect(
|
||||
x - BUTTON_FLAT_WIDTH / 2,
|
||||
y - TRIGGER_HEIGHT,
|
||||
BUTTON_FLAT_WIDTH,
|
||||
TRIGGER_HEIGHT,
|
||||
)
|
||||
)
|
||||
# Render button value
|
||||
c.paint.style = c.paint.Style.FILL
|
||||
height = value * TRIGGER_HEIGHT
|
||||
c.draw_rect(
|
||||
Rect(
|
||||
x - BUTTON_FLAT_WIDTH / 2,
|
||||
y - height,
|
||||
BUTTON_FLAT_WIDTH,
|
||||
height,
|
||||
)
|
||||
)
|
||||
# Render value text
|
||||
text = str(round(value * 100))
|
||||
text_rect = c.paint.measure_text(text)[1]
|
||||
c.draw_text(
|
||||
text,
|
||||
x - text_rect.x - text_rect.width / 2,
|
||||
y - TRIGGER_HEIGHT - text_rect.height,
|
||||
)
|
||||
|
||||
|
||||
def render_stick(
|
||||
c: SkiaCanvas, x: float, y: float, is_pressed: bool, value_x: float, value_y: float
|
||||
):
|
||||
# Stick click
|
||||
if is_pressed:
|
||||
c.paint.style = c.paint.Style.FILL
|
||||
c.draw_circle(x, y, CIRCLE_RADIUS)
|
||||
return
|
||||
|
||||
c.paint.style = c.paint.Style.STROKE
|
||||
# Draw outer circle
|
||||
c.draw_circle(x, y, CIRCLE_RADIUS)
|
||||
# Draw cross
|
||||
c.draw_line(x - CIRCLE_RADIUS, y, x + CIRCLE_RADIUS, y)
|
||||
c.draw_line(x, y - CIRCLE_RADIUS, x, y + CIRCLE_RADIUS)
|
||||
dot_x = x + value_x * CIRCLE_RADIUS
|
||||
dot_y = y + value_y * CIRCLE_RADIUS
|
||||
# Draw line to dot
|
||||
c.draw_line(x, y, dot_x, dot_y)
|
||||
# Draw center dot
|
||||
c.paint.style = c.paint.Style.FILL
|
||||
c.draw_circle(dot_x, dot_y, 5)
|
||||
# Render value texts
|
||||
text = f"{round(value_x * 100)}, {round(value_y * 100)}"
|
||||
text_rect = c.paint.measure_text(text)[1]
|
||||
c.draw_text(
|
||||
text,
|
||||
x - text_rect.x - text_rect.width / 2,
|
||||
y - CIRCLE_RADIUS - text_rect.height,
|
||||
)
|
||||
|
||||
|
||||
def render_close_text(c: SkiaCanvas, x: float, y: float):
|
||||
text = 'Say "gamepad tester" to close'
|
||||
text_rect = c.paint.measure_text(text)[1]
|
||||
c.draw_text(
|
||||
text,
|
||||
x - text_rect.x - text_rect.width / 2,
|
||||
y - 2 * text_rect.height,
|
||||
)
|
||||
|
||||
|
||||
def on_draw(c: SkiaCanvas):
|
||||
c.paint.textsize = FONT_SIZE
|
||||
|
||||
# Render background
|
||||
c.paint.style = c.paint.Style.FILL
|
||||
c.paint.color = BACKGROUND_COLOR
|
||||
c.draw_rect(c.rect)
|
||||
|
||||
c.paint.color = BORDER_COLOR
|
||||
y_center = c.rect.center.y + ROW_OFFSET * 0.75
|
||||
|
||||
offset = CIRCLE_RADIUS * 2.5
|
||||
|
||||
# Draw trigger buttons
|
||||
y = y_center - ROW_OFFSET * 2.5
|
||||
render_trigger(c, c.rect.center.x - offset, y, triggers["l2"])
|
||||
render_trigger(c, c.rect.center.x + offset, y, triggers["r2"])
|
||||
|
||||
# Draw bumper buttons
|
||||
y = y_center - ROW_OFFSET * 2.15
|
||||
render_flat_button(c, c.rect.center.x - offset, y, buttons["l1"])
|
||||
render_flat_button(c, c.rect.center.x + offset, y, buttons["r1"])
|
||||
|
||||
y = y_center - ROW_OFFSET
|
||||
|
||||
# Draw D-pad buttons
|
||||
render_buttons(
|
||||
c,
|
||||
c.rect.center.x - offset,
|
||||
y,
|
||||
False,
|
||||
["dpad_up", "dpad_right", "dpad_down", "dpad_left"],
|
||||
)
|
||||
|
||||
# Draw compass buttons
|
||||
render_buttons(
|
||||
c,
|
||||
c.rect.center.x + offset,
|
||||
y,
|
||||
True,
|
||||
["north", "east", "south", "west"],
|
||||
)
|
||||
|
||||
# Draw select/start buttons
|
||||
offset = CIRCLE_RADIUS * 0.75
|
||||
y = y_center - ROW_OFFSET / 3
|
||||
render_round_button(c, c.rect.center.x - offset, y, buttons["select"])
|
||||
render_round_button(c, c.rect.center.x + offset, y, buttons["start"])
|
||||
|
||||
# Draw sticks
|
||||
offset = CIRCLE_RADIUS * 1.5
|
||||
y = y_center + ROW_OFFSET
|
||||
render_stick(c, c.rect.center.x - offset, y, buttons["l3"], *sticks["left"])
|
||||
render_stick(c, c.rect.center.x + offset, y, buttons["r3"], *sticks["right"])
|
||||
|
||||
# Draw close text
|
||||
render_close_text(c, c.rect.center.x, c.rect.bot)
|
||||
|
||||
|
||||
def on_mouse(e: MouseEvent):
|
||||
global last_mouse_pos
|
||||
if e.event == "mousedown" and e.button == 0:
|
||||
last_mouse_pos = e.gpos
|
||||
elif e.event == "mousemove" and last_mouse_pos:
|
||||
dx = e.gpos.x - last_mouse_pos.x
|
||||
dy = e.gpos.y - last_mouse_pos.y
|
||||
last_mouse_pos = e.gpos
|
||||
if canvas is not None:
|
||||
canvas.move(canvas.rect.x + dx, canvas.rect.y + dy)
|
||||
elif e.event == "mouseup" and e.button == 0:
|
||||
last_mouse_pos = None
|
||||
|
||||
|
||||
def show():
|
||||
global canvas
|
||||
screen: Screen = ui.main_screen()
|
||||
x = screen.rect.center.x
|
||||
y = screen.rect.center.y
|
||||
canvas = Canvas.from_rect(Rect(x - WIDTH / 2, y - HEIGHT / 2, WIDTH, HEIGHT))
|
||||
canvas.draggable = True
|
||||
canvas.blocks_mouse = True
|
||||
canvas.register("draw", on_draw)
|
||||
canvas.register("mouse", on_mouse)
|
||||
ctx.tags = ["user.gamepad_tester"]
|
||||
|
||||
|
||||
def hide():
|
||||
global canvas
|
||||
if canvas is not None:
|
||||
canvas.unregister("draw", on_draw)
|
||||
canvas.unregister("mouse", on_mouse)
|
||||
canvas.close()
|
||||
canvas = None
|
||||
ctx.tags = []
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def gamepad_tester_toggle():
|
||||
"""Toggle visibility of gamepad tester gui"""
|
||||
if canvas is None:
|
||||
show()
|
||||
else:
|
||||
hide()
|
||||
|
||||
def gamepad_tester_button(id: str, is_pressed: bool):
|
||||
"""Indicates that a gamepad button has changed state"""
|
||||
buttons[id] = is_pressed
|
||||
|
||||
def gamepad_tester_trigger(id: str, value: float):
|
||||
"""Indicates that a gamepad trigger has changed state"""
|
||||
triggers[id] = value
|
||||
|
||||
def gamepad_tester_stick(id: str, x: float, y: float):
|
||||
"""Indicates that a gamepad stick has changed state"""
|
||||
sticks[id] = (x, y)
|
||||
@@ -0,0 +1 @@
|
||||
gamepad tester: user.gamepad_tester_toggle()
|
||||
@@ -0,0 +1,48 @@
|
||||
tag: user.gamepad_tester
|
||||
-
|
||||
|
||||
# D-pad buttons
|
||||
gamepad(dpad_up:up): user.gamepad_tester_button("dpad_up", false)
|
||||
gamepad(dpad_up:down): user.gamepad_tester_button("dpad_up", true)
|
||||
gamepad(dpad_right:up): user.gamepad_tester_button("dpad_right", false)
|
||||
gamepad(dpad_right:down): user.gamepad_tester_button("dpad_right", true)
|
||||
gamepad(dpad_down:up): user.gamepad_tester_button("dpad_down", false)
|
||||
gamepad(dpad_down:down): user.gamepad_tester_button("dpad_down", true)
|
||||
gamepad(dpad_left:up): user.gamepad_tester_button("dpad_left", false)
|
||||
gamepad(dpad_left:down): user.gamepad_tester_button("dpad_left", true)
|
||||
|
||||
# Compass buttons
|
||||
gamepad(north:up): user.gamepad_tester_button("north", false)
|
||||
gamepad(north:down): user.gamepad_tester_button("north", true)
|
||||
gamepad(east:up): user.gamepad_tester_button("east", false)
|
||||
gamepad(east:down): user.gamepad_tester_button("east", true)
|
||||
gamepad(south:up): user.gamepad_tester_button("south", false)
|
||||
gamepad(south:down): user.gamepad_tester_button("south", true)
|
||||
gamepad(west:up): user.gamepad_tester_button("west", false)
|
||||
gamepad(west:down): user.gamepad_tester_button("west", true)
|
||||
|
||||
# Select/start buttons
|
||||
gamepad(select:up): user.gamepad_tester_button("select", false)
|
||||
gamepad(select:down): user.gamepad_tester_button("select", true)
|
||||
gamepad(start:up): user.gamepad_tester_button("start", false)
|
||||
gamepad(start:down): user.gamepad_tester_button("start", true)
|
||||
|
||||
# Bumper buttons
|
||||
gamepad(l1:up): user.gamepad_tester_button("l1", false)
|
||||
gamepad(l1:down): user.gamepad_tester_button("l1", true)
|
||||
gamepad(r1:up): user.gamepad_tester_button("r1", false)
|
||||
gamepad(r1:down): user.gamepad_tester_button("r1", true)
|
||||
|
||||
# Stick click buttons
|
||||
gamepad(l3:up): user.gamepad_tester_button("l3", false)
|
||||
gamepad(l3:down): user.gamepad_tester_button("l3", true)
|
||||
gamepad(r3:up): user.gamepad_tester_button("r3", false)
|
||||
gamepad(r3:down): user.gamepad_tester_button("r3", true)
|
||||
|
||||
# Trigger buttons
|
||||
gamepad(l2:change): user.gamepad_tester_trigger("l2", value)
|
||||
gamepad(r2:change): user.gamepad_tester_trigger("r2", value)
|
||||
|
||||
# Sticks axis
|
||||
gamepad(left_xy): user.gamepad_tester_stick("left", x, y*-1)
|
||||
gamepad(right_xy): user.gamepad_tester_stick("right", x, y*-1)
|
||||
@@ -0,0 +1,106 @@
|
||||
import time
|
||||
|
||||
from talon import Context, Module, actions, app, cron, settings, speech_system, ui
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
mod.setting(
|
||||
"listening_timeout_minutes",
|
||||
int,
|
||||
default=-1,
|
||||
desc="After X mintues, disable speech recognition",
|
||||
)
|
||||
mod.setting(
|
||||
"listening_timeout_show_notification",
|
||||
bool,
|
||||
default=True,
|
||||
desc="After the timeout expires, display a notification",
|
||||
)
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class UserActions:
|
||||
def listening_timeout_expired():
|
||||
"""Action called when the listening timeout expires"""
|
||||
actions.speech.disable()
|
||||
|
||||
if settings.get("user.listening_timeout_show_notification"):
|
||||
show_notification()
|
||||
|
||||
|
||||
@ctx.action_class("speech")
|
||||
class SpeechActions:
|
||||
def enable():
|
||||
global last_phrase_time
|
||||
actions.next()
|
||||
start_timeout_job(calculate_timeout())
|
||||
last_phrase_time = time.perf_counter()
|
||||
|
||||
def disable():
|
||||
stop_timeout_job()
|
||||
actions.next()
|
||||
|
||||
|
||||
last_phrase_time = None
|
||||
timeout_job = None
|
||||
|
||||
|
||||
def start_timeout_job(timeout):
|
||||
global timeout_job
|
||||
cron.cancel(timeout_job)
|
||||
|
||||
if timeout > 0:
|
||||
timeout_job = cron.after(f"{timeout}s", check_timeout)
|
||||
|
||||
|
||||
def stop_timeout_job():
|
||||
global timeout_job
|
||||
cron.cancel(timeout_job)
|
||||
timeout_job = None
|
||||
|
||||
|
||||
def calculate_timeout():
|
||||
return settings.get("user.listening_timeout_minutes") * 60
|
||||
|
||||
|
||||
def check_timeout():
|
||||
global last_phrase_time, timeout_job
|
||||
timeout = calculate_timeout()
|
||||
if time.perf_counter() - last_phrase_time > timeout:
|
||||
actions.user.listening_timeout_expired()
|
||||
stop_timeout_job()
|
||||
elif timeout > 0:
|
||||
start_timeout_job(timeout)
|
||||
|
||||
|
||||
def post_phrase(e):
|
||||
global last_phrase_time, timeout_job
|
||||
last_phrase_time = time.perf_counter()
|
||||
timeout = calculate_timeout()
|
||||
|
||||
if timeout > 0:
|
||||
if actions.speech.enabled():
|
||||
start_timeout_job(timeout)
|
||||
else:
|
||||
stop_timeout_job()
|
||||
|
||||
|
||||
def show_notification():
|
||||
actions.app.notify(
|
||||
f"Speech recognition disabled - no speech detected for {calculate_timeout()}s"
|
||||
)
|
||||
|
||||
|
||||
def on_ready():
|
||||
global last_phrase_time
|
||||
last_phrase_time = time.perf_counter()
|
||||
|
||||
# in case talon starts up with speech enabled,
|
||||
# let's attempt to respect the timeout
|
||||
if actions.speech.enabled():
|
||||
start_timeout_job(calculate_timeout())
|
||||
|
||||
speech_system.register("post:phrase", post_phrase)
|
||||
|
||||
|
||||
app.register("ready", on_ready)
|
||||
@@ -0,0 +1,102 @@
|
||||
from talon import Context, Module, actions, clip, imgui, speech_system
|
||||
|
||||
mod = Module()
|
||||
|
||||
mod.list(
|
||||
"saved_macros",
|
||||
desc="list of macros that have been saved with the 'macro save' command",
|
||||
)
|
||||
ctx = Context()
|
||||
|
||||
macros = {}
|
||||
macro = []
|
||||
recording = False
|
||||
|
||||
|
||||
@imgui.open(y=0)
|
||||
def macro_list_gui(gui: imgui.GUI):
|
||||
gui.text("macros")
|
||||
gui.line()
|
||||
for command_name in macros.keys():
|
||||
gui.text(command_name)
|
||||
|
||||
if gui.button("macro list close"):
|
||||
actions.user.macro_list_close()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def macro_record():
|
||||
"""Begin recording a new voice command macro."""
|
||||
global macro
|
||||
global recording
|
||||
|
||||
macro = []
|
||||
recording = True
|
||||
|
||||
def macro_stop():
|
||||
"""Stop recording the macro."""
|
||||
global recording
|
||||
if recording and len(macro) != 0:
|
||||
# Remove the final `macro stop`/`macro play`/`macro save` command
|
||||
macro.pop()
|
||||
recording = False
|
||||
|
||||
def macro_save(name: str):
|
||||
"""Save the macro."""
|
||||
actions.user.macro_stop()
|
||||
macros[name] = macro
|
||||
|
||||
ctx.lists["user.saved_macros"] = macros.keys()
|
||||
|
||||
def macro_list():
|
||||
"""List all saved macros."""
|
||||
macro_list_gui.show()
|
||||
|
||||
def macro_list_close():
|
||||
"""Closed the saved macros list."""
|
||||
macro_list_gui.hide()
|
||||
|
||||
def macro_play(name: str):
|
||||
"""Execute the commands in the last recorded macro."""
|
||||
actions.user.macro_stop()
|
||||
|
||||
selected_macro = macro
|
||||
if name in macros:
|
||||
selected_macro = macros[name]
|
||||
|
||||
for words in selected_macro:
|
||||
print(words)
|
||||
actions.mimic(words)
|
||||
|
||||
def macro_copy(name: str):
|
||||
"""Copied the specified macro to the clipboard as a Talon command."""
|
||||
selected_macro = macro
|
||||
|
||||
if not name:
|
||||
# No macro name was provided, so we'll copy the most recent command
|
||||
# with this default name
|
||||
name = "last macro command"
|
||||
elif name in macros:
|
||||
selected_macro = macros[name]
|
||||
|
||||
l = [name + ":"]
|
||||
|
||||
for words in selected_macro:
|
||||
l.append(f'\tmimic("{" ".join(words)}")')
|
||||
|
||||
clip.set_text("\n".join(l))
|
||||
|
||||
def macro_append_command(words: list[str]):
|
||||
"""Appends a command to the current macro; called when a voice command is uttered while recording a macro."""
|
||||
assert recording, "Not currently recording a macro"
|
||||
macro.append(words)
|
||||
|
||||
|
||||
def fn(d):
|
||||
if not recording or "parsed" not in d:
|
||||
return
|
||||
actions.user.macro_append_command(d["parsed"]._unmapped)
|
||||
|
||||
|
||||
speech_system.register("pre:phrase", fn)
|
||||
@@ -0,0 +1,8 @@
|
||||
macro record: user.macro_record()
|
||||
macro stop: user.macro_stop()
|
||||
macro play [{user.saved_macros}]: user.macro_play(saved_macros or "")
|
||||
macro copy [{user.saved_macros}]: user.macro_copy(saved_macros or "")
|
||||
macro copy as <user.text>: user.macro_copy(text)
|
||||
macro save as <user.text>: user.macro_save(text)
|
||||
macro list: user.macro_list()
|
||||
macro list close: user.macro_list_close()
|
||||
@@ -0,0 +1,13 @@
|
||||
from talon import Module, actions, app
|
||||
|
||||
mod = Module()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def play_pause():
|
||||
"""Plays or pauses media"""
|
||||
if app.platform == "windows":
|
||||
actions.key("play_pause")
|
||||
else:
|
||||
actions.key("play")
|
||||
@@ -0,0 +1,7 @@
|
||||
volume up: key(volup)
|
||||
volume down: key(voldown)
|
||||
set volume <number>: user.media_set_volume(number)
|
||||
(volume | media) mute: key(mute)
|
||||
[media] play next: key(next)
|
||||
[media] play previous: key(prev)
|
||||
media (play | pause): user.play_pause()
|
||||
@@ -0,0 +1,71 @@
|
||||
from talon import Module, actions, app, imgui
|
||||
from talon.lib import cubeb
|
||||
|
||||
ctx = cubeb.Context()
|
||||
mod = Module()
|
||||
|
||||
|
||||
microphone_device_list = []
|
||||
|
||||
|
||||
# by convention, None and System Default are listed first
|
||||
# to match the Talon context menu.
|
||||
def update_microphone_list():
|
||||
global microphone_device_list
|
||||
microphone_device_list = ["None", "System Default"]
|
||||
|
||||
# On Windows, it's presently necessary to check the state, or
|
||||
# we will get any and every microphone that was ever connected.
|
||||
devices = [
|
||||
dev.name for dev in ctx.inputs() if dev.state == cubeb.DeviceState.ENABLED
|
||||
]
|
||||
|
||||
devices.sort()
|
||||
microphone_device_list += devices
|
||||
|
||||
|
||||
def devices_changed(device_type):
|
||||
update_microphone_list()
|
||||
|
||||
|
||||
@imgui.open()
|
||||
def gui(gui: imgui.GUI):
|
||||
gui.text("Select a Microphone")
|
||||
gui.line()
|
||||
for index, item in enumerate(microphone_device_list, 1):
|
||||
if gui.button(f"{index}. {item}"):
|
||||
actions.user.microphone_select(index)
|
||||
|
||||
gui.spacer()
|
||||
if gui.button("Microphone close"):
|
||||
actions.user.microphone_selection_hide()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def microphone_selection_toggle():
|
||||
"""Show GUI for choosing the Talon microphone"""
|
||||
if gui.showing:
|
||||
gui.hide()
|
||||
else:
|
||||
update_microphone_list()
|
||||
gui.show()
|
||||
|
||||
def microphone_selection_hide():
|
||||
"""Hide the microphone selection GUI"""
|
||||
gui.hide()
|
||||
|
||||
def microphone_select(index: int):
|
||||
"""Selects a micropohone"""
|
||||
if 1 <= index and index <= len(microphone_device_list):
|
||||
actions.sound.set_microphone(microphone_device_list[index - 1])
|
||||
app.notify(f"Activating microphone: {microphone_device_list[index - 1]}")
|
||||
gui.hide()
|
||||
|
||||
|
||||
def on_ready():
|
||||
ctx.register("devices_changed", devices_changed)
|
||||
update_microphone_list()
|
||||
|
||||
|
||||
app.register("ready", on_ready)
|
||||
@@ -0,0 +1,3 @@
|
||||
^microphone show$: user.microphone_selection_toggle()
|
||||
^microphone close$: user.microphone_selection_hide()
|
||||
^microphone pick <number_small>$: user.microphone_select(number_small)
|
||||
@@ -0,0 +1,34 @@
|
||||
# Mode indicator
|
||||
|
||||
Graphical indicator to show you which Talon mode your currently are in. Supports a lot of settings to move, resize and change colors.
|
||||
|
||||
## Default colors
|
||||
|
||||
Command mode
|
||||
|
||||

|
||||
|
||||
Dictation mode
|
||||
|
||||

|
||||
|
||||
Mixed mode
|
||||
|
||||

|
||||
|
||||
Sleep mode
|
||||
|
||||

|
||||
|
||||
Other modes
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
You can enable this by changing the following setting in `mode_indicator.talon`:
|
||||
`user.mode_indicator_show = true`
|
||||
|
||||
## Demo
|
||||
|
||||
[YouTube - Mode indicator demo](https://youtu.be/1lqtfM4vvH4)
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,228 @@
|
||||
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)
|
||||
@@ -0,0 +1,29 @@
|
||||
settings():
|
||||
# Don't show mode indicator by default
|
||||
user.mode_indicator_show = false
|
||||
# Set to true to show the first 2 letters of the microphone name inside the mode indicator
|
||||
user.mode_indicator_show_microphone_name = false
|
||||
# 30pixels diameter
|
||||
user.mode_indicator_size = 30
|
||||
# Center horizontally. (0=left, 0.5=center, 1=right)
|
||||
user.mode_indicator_x = 0.5
|
||||
# Align top. (0=top, 0.5=center, 1=bottom)
|
||||
user.mode_indicator_y = 0
|
||||
# Slightly transparent
|
||||
user.mode_indicator_color_alpha = 0.75
|
||||
# Grey gradient
|
||||
user.mode_indicator_color_gradient = 0.5
|
||||
# White color for optional text overlay on mode indicator
|
||||
user.mode_indicator_color_text = "eeeeee"
|
||||
# Black color for when the microphone is muted (set to "None")
|
||||
user.mode_indicator_color_mute = "000000"
|
||||
# Grey color for sleep mode
|
||||
user.mode_indicator_color_sleep = "808080"
|
||||
# Gold color for dictation mode
|
||||
user.mode_indicator_color_dictation = "ffd700"
|
||||
# MediumSeaGreen color for mixed mode
|
||||
user.mode_indicator_color_mixed = "3cb371"
|
||||
# CornflowerBlue color for command mode
|
||||
user.mode_indicator_color_command = "6495ed"
|
||||
# GhostWhite color for other modes
|
||||
user.mode_indicator_color_other = "f8f8ff"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Mouse
|
||||
|
||||
## Continuous Scrolling
|
||||
|
||||
You can start continuous scrolling by saying "wheel upper" or "wheel downer" and stop by saying "wheel stop". Saying "here" after one of the scrolling commands first moves the cursor to the middle of the window. A number between 1 and 99 can be dictated at the end of a scrolling command to set the scrolling speed. Dictating a continuous scrolling command in the same direction twice stops the scrolling.
|
||||
|
||||
During continuous scrolling, you can dictate a number between 0 and 99 to change the scrolling speed. The resulting speed is the user.mouse_continuous_scroll_amount setting multiplied by the number you dictated divided by the user.mouse_continuous_scroll_speed_quotient setting (which defaults to 10). With default settings, dictating 5 gives you half speed and dictating 20 gives you double speed. Note: Because the scrolling speed has to be an integer number, changing the speed by a small amount like 1 might not change how fast scrolling actually happens depending on your settings. The final scrolling speed is chosen by rounding and enforcing a minimum speed of 1.
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -0,0 +1,8 @@
|
||||
tag: user.continuous_scrolling
|
||||
-
|
||||
<number_small>: user.mouse_scroll_set_speed(number_small)
|
||||
|
||||
[wheel] stop: user.mouse_scroll_stop()
|
||||
[wheel] stop here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_stop()
|
||||
@@ -0,0 +1,6 @@
|
||||
list: user.continuous_scrolling_direction
|
||||
-
|
||||
upper: UP
|
||||
downer: DOWN
|
||||
righter: RIGHT
|
||||
lefter: LEFT
|
||||
@@ -0,0 +1,135 @@
|
||||
from talon import Context, Module, actions, ctrl, settings, ui
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
|
||||
mod.list(
|
||||
"mouse_button",
|
||||
desc="List of mouse button words to mouse_click index parameter",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_enable_pop_click",
|
||||
type=int,
|
||||
default=0,
|
||||
desc="Pop noise clicks left mouse button. 0 = off, 1 = on with eyetracker but not with zoom mouse mode, 2 = on but not with zoom mouse mode",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_enable_pop_stops_scroll",
|
||||
type=bool,
|
||||
default=False,
|
||||
desc="When enabled, pop stops continuous scroll modes (wheel upper/downer/gaze)",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_enable_pop_stops_drag",
|
||||
type=bool,
|
||||
default=False,
|
||||
desc="When enabled, pop stops mouse drag",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_wake_hides_cursor",
|
||||
type=bool,
|
||||
default=False,
|
||||
desc="When enabled, mouse wake will hide the cursor. mouse_wake enables zoom mouse.",
|
||||
)
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def zoom_close():
|
||||
"""Closes an in-progress zoom. Talon will move the cursor position but not click."""
|
||||
actions.user.deprecate_action(
|
||||
"2024-12-26",
|
||||
"user.zoom_close",
|
||||
"tracking.zoom_cancel",
|
||||
)
|
||||
actions.tracking.zoom_cancel()
|
||||
|
||||
def mouse_wake():
|
||||
"""Enable control mouse, zoom mouse, and disables cursor"""
|
||||
actions.tracking.control_zoom_toggle(True)
|
||||
|
||||
if settings.get("user.mouse_wake_hides_cursor"):
|
||||
actions.user.mouse_cursor_hide()
|
||||
|
||||
def mouse_drag(button: int):
|
||||
"""Press and hold/release a specific mouse button for dragging"""
|
||||
# Clear any existing drags
|
||||
actions.user.mouse_drag_end()
|
||||
|
||||
# Start drag
|
||||
actions.mouse_drag(button)
|
||||
|
||||
def mouse_drag_end() -> bool:
|
||||
"""Releases any held mouse buttons"""
|
||||
buttons = ctrl.mouse_buttons_down()
|
||||
if buttons:
|
||||
for button in buttons:
|
||||
actions.mouse_release(button)
|
||||
return True
|
||||
return False
|
||||
|
||||
def mouse_drag_toggle(button: int):
|
||||
"""If the button is held down, release the button, else start dragging"""
|
||||
if button in ctrl.mouse_buttons_down():
|
||||
actions.mouse_release(button)
|
||||
else:
|
||||
actions.mouse_drag(button)
|
||||
|
||||
def mouse_sleep():
|
||||
"""Disables control mouse, zoom mouse, and re-enables cursor"""
|
||||
actions.tracking.control_zoom_toggle(False)
|
||||
actions.tracking.control_toggle(False)
|
||||
actions.tracking.control1_toggle(False)
|
||||
|
||||
actions.user.mouse_cursor_show()
|
||||
actions.user.mouse_scroll_stop()
|
||||
actions.user.mouse_drag_end()
|
||||
|
||||
def copy_mouse_position():
|
||||
"""Copy the current mouse position coordinates"""
|
||||
x, y = actions.mouse_x(), actions.mouse_y()
|
||||
actions.clip.set_text(f"{x}, {y}")
|
||||
|
||||
def mouse_move_center_active_window():
|
||||
"""Move the mouse cursor to the center of the currently active window"""
|
||||
rect = ui.active_window().rect
|
||||
actions.mouse_move(rect.center.x, rect.center.y)
|
||||
|
||||
|
||||
@ctx.action_class("user")
|
||||
class UserActions:
|
||||
def noise_trigger_pop():
|
||||
dont_click = False
|
||||
|
||||
# Allow pop to stop drag
|
||||
if settings.get("user.mouse_enable_pop_stops_drag"):
|
||||
if actions.user.mouse_drag_end():
|
||||
dont_click = True
|
||||
|
||||
# Allow pop to stop scroll
|
||||
if settings.get("user.mouse_enable_pop_stops_scroll"):
|
||||
if actions.user.mouse_scroll_stop():
|
||||
dont_click = True
|
||||
|
||||
if dont_click:
|
||||
return
|
||||
|
||||
# Otherwise respect the mouse_enable_pop_click setting
|
||||
setting_val = settings.get("user.mouse_enable_pop_click")
|
||||
|
||||
is_using_eye_tracker = (
|
||||
actions.tracking.control_zoom_enabled()
|
||||
or actions.tracking.control_enabled()
|
||||
or actions.tracking.control1_enabled()
|
||||
)
|
||||
|
||||
should_click = (
|
||||
setting_val == 2 and not actions.tracking.control_zoom_enabled()
|
||||
) or (
|
||||
setting_val == 1
|
||||
and is_using_eye_tracker
|
||||
and not actions.tracking.control_zoom_enabled()
|
||||
)
|
||||
|
||||
if should_click:
|
||||
ctrl.mouse_click(button=0, hold=16000)
|
||||
@@ -0,0 +1,134 @@
|
||||
control mouse: tracking.control_toggle()
|
||||
control off: user.mouse_sleep()
|
||||
zoom mouse: tracking.control_zoom_toggle()
|
||||
camera overlay: tracking.control_debug_toggle()
|
||||
run calibration: tracking.calibrate()
|
||||
touch:
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
mouse_click(0)
|
||||
# close the mouse grid if open
|
||||
user.grid_close()
|
||||
# End any open drags
|
||||
# Touch automatically ends left drags so this is for right drags specifically
|
||||
user.mouse_drag_end()
|
||||
|
||||
righty:
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
mouse_click(1)
|
||||
# close the mouse grid if open
|
||||
user.grid_close()
|
||||
|
||||
mid click:
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
mouse_click(2)
|
||||
# close the mouse grid
|
||||
user.grid_close()
|
||||
|
||||
#see keys.py for modifiers.
|
||||
#defaults
|
||||
#command
|
||||
#control
|
||||
#option = alt
|
||||
#shift
|
||||
#super = windows key
|
||||
<user.modifiers> touch:
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
key("{modifiers}:down")
|
||||
mouse_click(0)
|
||||
key("{modifiers}:up")
|
||||
# close the mouse grid
|
||||
user.grid_close()
|
||||
<user.modifiers> righty:
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
key("{modifiers}:down")
|
||||
mouse_click(1)
|
||||
key("{modifiers}:up")
|
||||
# close the mouse grid
|
||||
user.grid_close()
|
||||
(dub click | duke):
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
mouse_click()
|
||||
mouse_click()
|
||||
# close the mouse grid
|
||||
user.grid_close()
|
||||
(trip click | trip lick):
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
mouse_click()
|
||||
mouse_click()
|
||||
mouse_click()
|
||||
# close the mouse grid
|
||||
user.grid_close()
|
||||
left drag | drag | drag start:
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
user.mouse_drag(0)
|
||||
# close the mouse grid
|
||||
user.grid_close()
|
||||
right drag | righty drag:
|
||||
# close zoom if open
|
||||
tracking.zoom_cancel()
|
||||
user.mouse_drag(1)
|
||||
# close the mouse grid
|
||||
user.grid_close()
|
||||
end drag | drag end: user.mouse_drag_end()
|
||||
wheel down: user.mouse_scroll_down()
|
||||
wheel down here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_down()
|
||||
wheel tiny [down]: user.mouse_scroll_down(0.2)
|
||||
wheel tiny [down] here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_down(0.2)
|
||||
wheel up: user.mouse_scroll_up()
|
||||
wheel up here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_up()
|
||||
wheel tiny up: user.mouse_scroll_up(0.2)
|
||||
wheel tiny up here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_up(0.2)
|
||||
wheel gaze: user.mouse_gaze_scroll()
|
||||
wheel gaze here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_gaze_scroll()
|
||||
wheel left: user.mouse_scroll_left()
|
||||
wheel left here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_left()
|
||||
wheel tiny left: user.mouse_scroll_left(0.5)
|
||||
wheel tiny left here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_left(0.5)
|
||||
wheel right: user.mouse_scroll_right()
|
||||
wheel right here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_right()
|
||||
wheel tiny right: user.mouse_scroll_right(0.5)
|
||||
wheel tiny right here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_right(0.5)
|
||||
wheel {user.continuous_scrolling_direction}:
|
||||
user.mouse_scroll_continuous(continuous_scrolling_direction)
|
||||
wheel {user.continuous_scrolling_direction} here:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_continuous(continuous_scrolling_direction)
|
||||
wheel {user.continuous_scrolling_direction} <number_small>:
|
||||
user.mouse_scroll_continuous(continuous_scrolling_direction, number_small)
|
||||
wheel {user.continuous_scrolling_direction} here <number_small>:
|
||||
user.mouse_move_center_active_window()
|
||||
user.mouse_scroll_continuous(continuous_scrolling_direction, number_small)
|
||||
copy mouse position: user.copy_mouse_position()
|
||||
curse no:
|
||||
# Command added 2021-12-13, can remove after 2022-06-01
|
||||
app.notify("Please activate the user.mouse_cursor_commands_enable tag to enable this command")
|
||||
|
||||
# To scroll with a hiss sound, set mouse_enable_hiss_scroll to true in settings.talon
|
||||
mouse hiss up: user.hiss_scroll_up()
|
||||
mouse hiss down: user.hiss_scroll_down()
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
|
||||
from talon import Module, app, ctrl
|
||||
|
||||
default_cursor = {
|
||||
"AppStarting": r"%SystemRoot%\Cursors\aero_working.ani",
|
||||
"Arrow": r"%SystemRoot%\Cursors\aero_arrow.cur",
|
||||
"Hand": r"%SystemRoot%\Cursors\aero_link.cur",
|
||||
"Help": r"%SystemRoot%\Cursors\aero_helpsel.cur",
|
||||
"No": r"%SystemRoot%\Cursors\aero_unavail.cur",
|
||||
"NWPen": r"%SystemRoot%\Cursors\aero_pen.cur",
|
||||
"Person": r"%SystemRoot%\Cursors\aero_person.cur",
|
||||
"Pin": r"%SystemRoot%\Cursors\aero_pin.cur",
|
||||
"SizeAll": r"%SystemRoot%\Cursors\aero_move.cur",
|
||||
"SizeNESW": r"%SystemRoot%\Cursors\aero_nesw.cur",
|
||||
"SizeNS": r"%SystemRoot%\Cursors\aero_ns.cur",
|
||||
"SizeNWSE": r"%SystemRoot%\Cursors\aero_nwse.cur",
|
||||
"SizeWE": r"%SystemRoot%\Cursors\aero_ew.cur",
|
||||
"UpArrow": r"%SystemRoot%\Cursors\aero_up.cur",
|
||||
"Wait": r"%SystemRoot%\Cursors\aero_busy.ani",
|
||||
"Crosshair": "",
|
||||
"IBeam": "",
|
||||
}
|
||||
|
||||
# todo figure out why notepad++ still shows the cursor sometimes.
|
||||
hidden_cursor = os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), r"Resources\HiddenCursor.cur"
|
||||
)
|
||||
|
||||
mod = Module()
|
||||
|
||||
mod.tag(
|
||||
"mouse_cursor_commands_enable",
|
||||
desc="Tag enables hide/show mouse cursor commands",
|
||||
)
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def mouse_cursor_show():
|
||||
"""Shows the cursor"""
|
||||
show_cursor_helper(True)
|
||||
|
||||
def mouse_cursor_hide():
|
||||
"""Hides the cursor"""
|
||||
show_cursor_helper(False)
|
||||
|
||||
|
||||
def show_cursor_helper(show: bool):
|
||||
"""Show/hide the cursor"""
|
||||
if app.platform == "windows":
|
||||
import ctypes
|
||||
import winreg
|
||||
|
||||
import win32con
|
||||
|
||||
try:
|
||||
Registrykey = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER, r"Control Panel\Cursors", 0, winreg.KEY_WRITE
|
||||
)
|
||||
|
||||
for value_name, value in default_cursor.items():
|
||||
if show:
|
||||
winreg.SetValueEx(
|
||||
Registrykey, value_name, 0, winreg.REG_EXPAND_SZ, value
|
||||
)
|
||||
else:
|
||||
winreg.SetValueEx(
|
||||
Registrykey, value_name, 0, winreg.REG_EXPAND_SZ, hidden_cursor
|
||||
)
|
||||
|
||||
winreg.CloseKey(Registrykey)
|
||||
|
||||
ctypes.windll.user32.SystemParametersInfoA(
|
||||
win32con.SPI_SETCURSORS, 0, None, 0
|
||||
)
|
||||
|
||||
except OSError:
|
||||
print(f"Unable to show_cursor({show})")
|
||||
else:
|
||||
ctrl.cursor_visible(show)
|
||||
@@ -0,0 +1,4 @@
|
||||
tag: user.mouse_cursor_commands_enable
|
||||
-
|
||||
curse yes: user.mouse_cursor_show()
|
||||
curse no: user.mouse_cursor_hide()
|
||||
@@ -0,0 +1,326 @@
|
||||
import time
|
||||
from typing import Literal, Optional
|
||||
|
||||
from talon import Context, Module, actions, app, cron, ctrl, imgui, settings, ui
|
||||
|
||||
continuous_scroll_mode = ""
|
||||
scroll_job = None
|
||||
gaze_job = None
|
||||
scroll_dir: Literal[-1, 1] = 1
|
||||
scroll_start_ts: float = 0
|
||||
hiss_scroll_up = False
|
||||
control_mouse_forced = False
|
||||
continuous_scrolling_speed_factor: float = 1.0
|
||||
is_continuous_scrolling_vertical: bool = True
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
|
||||
mod.list(
|
||||
"continuous_scrolling_direction",
|
||||
desc="Defines names for directions used with continuous scrolling",
|
||||
)
|
||||
|
||||
mod.setting(
|
||||
"mouse_wheel_down_amount",
|
||||
type=int,
|
||||
default=120,
|
||||
desc="The amount to scroll up/down (equivalent to mouse wheel on Windows by default)",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_wheel_horizontal_amount",
|
||||
type=int,
|
||||
default=40,
|
||||
desc="The amount to scroll left/right",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_continuous_scroll_amount",
|
||||
type=int,
|
||||
default=8,
|
||||
desc="The default amount used when scrolling continuously",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_continuous_scroll_acceleration",
|
||||
type=float,
|
||||
default=1,
|
||||
desc="The maximum (linear) acceleration factor when scrolling continuously. 1=constant speed/no acceleration",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_enable_hiss_scroll",
|
||||
type=bool,
|
||||
default=False,
|
||||
desc="Hiss noise scrolls down when enabled",
|
||||
)
|
||||
mod.setting(
|
||||
"mouse_hide_mouse_gui",
|
||||
type=bool,
|
||||
default=False,
|
||||
desc="When enabled, the 'Scroll Mouse' GUI will not be shown.",
|
||||
)
|
||||
|
||||
mod.setting(
|
||||
"mouse_continuous_scroll_speed_quotient",
|
||||
type=float,
|
||||
default=10.0,
|
||||
desc="When adjusting the continuous scrolling speed through voice commands, the result is that the speed is multiplied by the dictated number divided by this number.",
|
||||
)
|
||||
|
||||
mod.setting(
|
||||
"mouse_gaze_scroll_speed_multiplier",
|
||||
type=float,
|
||||
default=1.0,
|
||||
desc="This multiplies the gaze scroll speed",
|
||||
)
|
||||
|
||||
mod.tag(
|
||||
"continuous_scrolling",
|
||||
desc="Allows commands for adjusting continuous scrolling behavior",
|
||||
)
|
||||
|
||||
|
||||
@imgui.open(x=700, y=0)
|
||||
def gui_wheel(gui: imgui.GUI):
|
||||
gui.text(f"Scroll mode: {continuous_scroll_mode}")
|
||||
gui.text(f"say a number between 0 and 99 to set scrolling speed")
|
||||
gui.line()
|
||||
if gui.button("[Wheel] Stop"):
|
||||
actions.user.mouse_scroll_stop()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def mouse_scroll_up(amount: float = 1):
|
||||
"""Scrolls up"""
|
||||
y = amount * settings.get("user.mouse_wheel_down_amount")
|
||||
actions.mouse_scroll(-y)
|
||||
|
||||
def mouse_scroll_down(amount: float = 1):
|
||||
"""Scrolls down"""
|
||||
y = amount * settings.get("user.mouse_wheel_down_amount")
|
||||
actions.mouse_scroll(y)
|
||||
|
||||
def mouse_scroll_left(amount: float = 1):
|
||||
"""Scrolls left"""
|
||||
x = amount * settings.get("user.mouse_wheel_horizontal_amount")
|
||||
actions.mouse_scroll(0, -x)
|
||||
|
||||
def mouse_scroll_right(amount: float = 1):
|
||||
"""Scrolls right"""
|
||||
x = amount * settings.get("user.mouse_wheel_horizontal_amount")
|
||||
actions.mouse_scroll(0, x)
|
||||
|
||||
def mouse_scroll_continuous(direction: str, speed_factor: Optional[int] = None):
|
||||
"""Scrolls continuously in the given direction"""
|
||||
match direction:
|
||||
case "UP":
|
||||
actions.user.mouse_scroll_up_continuous(speed_factor)
|
||||
case "DOWN":
|
||||
actions.user.mouse_scroll_down_continuous(speed_factor)
|
||||
case "LEFT":
|
||||
actions.user.mouse_scroll_left_continuous(speed_factor)
|
||||
case "RIGHT":
|
||||
actions.user.mouse_scroll_right_continuous(speed_factor)
|
||||
case _:
|
||||
raise ValueError(f"Invalid continuous scrolling direction: {direction}")
|
||||
|
||||
def mouse_scroll_up_continuous(speed_factor: Optional[int] = None):
|
||||
"""Scrolls up continuously"""
|
||||
mouse_scroll_continuous(-1, speed_factor)
|
||||
|
||||
def mouse_scroll_down_continuous(speed_factor: Optional[int] = None):
|
||||
"""Scrolls down continuously"""
|
||||
mouse_scroll_continuous(1, speed_factor)
|
||||
|
||||
def mouse_scroll_right_continuous(speed_factor: Optional[int] = None):
|
||||
"""Scrolls right continuously"""
|
||||
mouse_scroll_continuous(1, speed_factor, is_vertical=False)
|
||||
|
||||
def mouse_scroll_left_continuous(speed_factor: Optional[int] = None):
|
||||
"""Scrolls left continuously"""
|
||||
mouse_scroll_continuous(-1, speed_factor, is_vertical=False)
|
||||
|
||||
def mouse_gaze_scroll():
|
||||
"""Starts gaze scroll"""
|
||||
global gaze_job, continuous_scroll_mode, control_mouse_forced
|
||||
|
||||
ctx.tags = ["user.continuous_scrolling"]
|
||||
|
||||
continuous_scroll_mode = "gaze scroll"
|
||||
gaze_job = cron.interval("16ms", scroll_gaze_helper)
|
||||
|
||||
if not settings.get("user.mouse_hide_mouse_gui"):
|
||||
gui_wheel.show()
|
||||
|
||||
# enable 'control mouse' if eye tracker is present and not enabled already
|
||||
if not actions.tracking.control_enabled():
|
||||
actions.tracking.control_toggle(True)
|
||||
control_mouse_forced = True
|
||||
|
||||
def mouse_gaze_scroll_toggle():
|
||||
"""If not scrolling, start gaze scroll, else stop scrolling."""
|
||||
if continuous_scroll_mode == "":
|
||||
actions.user.mouse_gaze_scroll()
|
||||
else:
|
||||
actions.user.mouse_scroll_stop()
|
||||
|
||||
def mouse_scroll_stop() -> bool:
|
||||
"""Stops scrolling"""
|
||||
global scroll_job, gaze_job, continuous_scroll_mode, control_mouse_forced, continuous_scrolling_speed_factor
|
||||
|
||||
continuous_scroll_mode = ""
|
||||
continuous_scrolling_speed_factor = 1.0
|
||||
return_value = False
|
||||
ctx.tags = []
|
||||
|
||||
if scroll_job:
|
||||
cron.cancel(scroll_job)
|
||||
scroll_job = None
|
||||
return_value = True
|
||||
|
||||
if gaze_job:
|
||||
cron.cancel(gaze_job)
|
||||
gaze_job = None
|
||||
return_value = True
|
||||
|
||||
if control_mouse_forced:
|
||||
actions.tracking.control_toggle(False)
|
||||
control_mouse_forced = False
|
||||
|
||||
gui_wheel.hide()
|
||||
|
||||
return return_value
|
||||
|
||||
def mouse_scroll_set_speed(speed: Optional[int]):
|
||||
"""Sets the continuous scrolling speed for the current scrolling"""
|
||||
global continuous_scrolling_speed_factor, scroll_start_ts
|
||||
if scroll_start_ts:
|
||||
scroll_start_ts = time.perf_counter()
|
||||
if speed is None:
|
||||
continuous_scrolling_speed_factor = 1.0
|
||||
else:
|
||||
continuous_scrolling_speed_factor = speed / settings.get(
|
||||
"user.mouse_continuous_scroll_speed_quotient"
|
||||
)
|
||||
|
||||
def mouse_is_continuous_scrolling():
|
||||
"""Returns whether continuous scroll is in progress"""
|
||||
return len(continuous_scroll_mode) > 0
|
||||
|
||||
def hiss_scroll_up():
|
||||
"""Change mouse hiss scroll direction to up"""
|
||||
global hiss_scroll_up
|
||||
hiss_scroll_up = True
|
||||
|
||||
def hiss_scroll_down():
|
||||
"""Change mouse hiss scroll direction to down"""
|
||||
global hiss_scroll_up
|
||||
hiss_scroll_up = False
|
||||
|
||||
|
||||
@ctx.action_class("user")
|
||||
class UserActions:
|
||||
def noise_trigger_hiss(active: bool):
|
||||
if settings.get("user.mouse_enable_hiss_scroll"):
|
||||
if active:
|
||||
if hiss_scroll_up:
|
||||
actions.user.mouse_scroll_up_continuous()
|
||||
else:
|
||||
actions.user.mouse_scroll_down_continuous()
|
||||
else:
|
||||
actions.user.mouse_scroll_stop()
|
||||
|
||||
|
||||
def mouse_scroll_continuous(
|
||||
new_scroll_dir: Literal[-1, 1],
|
||||
speed_factor: Optional[int] = None,
|
||||
is_vertical: bool = True,
|
||||
):
|
||||
global scroll_job, scroll_dir, scroll_start_ts, is_continuous_scrolling_vertical
|
||||
actions.user.mouse_scroll_set_speed(speed_factor)
|
||||
was_vertical = is_continuous_scrolling_vertical
|
||||
is_continuous_scrolling_vertical = is_vertical
|
||||
|
||||
update_continuous_scrolling_mode(new_scroll_dir, is_vertical)
|
||||
|
||||
if scroll_job:
|
||||
# Issuing a scroll in the same direction aborts scrolling
|
||||
if scroll_dir == new_scroll_dir and was_vertical == is_vertical:
|
||||
actions.user.mouse_scroll_stop()
|
||||
# Issuing a scroll in the reverse direction resets acceleration
|
||||
else:
|
||||
scroll_dir = new_scroll_dir
|
||||
scroll_start_ts = time.perf_counter()
|
||||
else:
|
||||
scroll_dir = new_scroll_dir
|
||||
scroll_start_ts = time.perf_counter()
|
||||
scroll_continuous_helper()
|
||||
scroll_job = cron.interval("16ms", scroll_continuous_helper)
|
||||
ctx.tags = ["user.continuous_scrolling"]
|
||||
|
||||
if not settings.get("user.mouse_hide_mouse_gui"):
|
||||
gui_wheel.show()
|
||||
|
||||
|
||||
def update_continuous_scrolling_mode(new_scroll_dir: Literal[-1, 1], is_vertical: bool):
|
||||
global continuous_scroll_mode
|
||||
if new_scroll_dir == -1:
|
||||
if is_vertical:
|
||||
continuous_scroll_mode = "scroll up continuous"
|
||||
else:
|
||||
continuous_scroll_mode = "scroll left continuous"
|
||||
else:
|
||||
if is_vertical:
|
||||
continuous_scroll_mode = "scroll down continuous"
|
||||
else:
|
||||
continuous_scroll_mode = "scroll right continuous"
|
||||
|
||||
|
||||
def scroll_continuous_helper():
|
||||
scroll_amount = (
|
||||
settings.get("user.mouse_continuous_scroll_amount")
|
||||
* continuous_scrolling_speed_factor
|
||||
)
|
||||
acceleration_setting = settings.get("user.mouse_continuous_scroll_acceleration")
|
||||
acceleration_speed = (
|
||||
1 + min((time.perf_counter() - scroll_start_ts) / 0.5, acceleration_setting - 1)
|
||||
if acceleration_setting > 1
|
||||
else 1
|
||||
)
|
||||
|
||||
scroll_delta = round(scroll_amount * acceleration_speed * scroll_dir)
|
||||
if scroll_delta == 0:
|
||||
scroll_delta = scroll_dir
|
||||
if is_continuous_scrolling_vertical:
|
||||
actions.mouse_scroll(scroll_delta)
|
||||
else:
|
||||
actions.mouse_scroll(0, scroll_delta)
|
||||
|
||||
|
||||
def scroll_gaze_helper():
|
||||
x, y = ctrl.mouse_pos()
|
||||
|
||||
# The window containing the mouse
|
||||
window = get_window_containing(x, y)
|
||||
|
||||
if window is None:
|
||||
return
|
||||
|
||||
rect = window.rect
|
||||
midpoint = rect.center.y
|
||||
factor = continuous_scrolling_speed_factor * settings.get(
|
||||
"user.mouse_gaze_scroll_speed_multiplier"
|
||||
)
|
||||
amount = factor * (((y - midpoint) / (rect.height / 10)) ** 3)
|
||||
actions.mouse_scroll(amount)
|
||||
|
||||
|
||||
def get_window_containing(x: float, y: float):
|
||||
# on windows, check the active_window first since ui.windows() is not z-ordered
|
||||
if app.platform == "windows" and ui.active_window().rect.contains(x, y):
|
||||
return ui.active_window()
|
||||
|
||||
for window in ui.windows():
|
||||
if window.rect.contains(x, y):
|
||||
return window
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,25 @@
|
||||
import logging
|
||||
|
||||
from talon import Context, Module, actions, settings
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
|
||||
mod.setting(
|
||||
"paste_to_insert_threshold",
|
||||
type=int,
|
||||
default=-1,
|
||||
desc="""Use paste to insert text longer than this many characters.
|
||||
Zero means always paste; -1 means never paste.
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
@ctx.action_class("main")
|
||||
class MainActions:
|
||||
def insert(text):
|
||||
threshold = settings.get("user.paste_to_insert_threshold")
|
||||
if 0 <= threshold < len(text):
|
||||
actions.user.paste(text)
|
||||
return
|
||||
actions.next(text)
|
||||
@@ -0,0 +1,30 @@
|
||||
import time
|
||||
|
||||
from talon import Context, Module, actions, settings
|
||||
|
||||
ctx = Context()
|
||||
mod = Module()
|
||||
|
||||
mod.tag("pop_twice_to_repeat", desc="tag for enabling pop twice to repeat")
|
||||
|
||||
ctx.matches = r"""
|
||||
mode: command
|
||||
and tag: user.pop_twice_to_repeat
|
||||
"""
|
||||
|
||||
time_last_pop = 0
|
||||
|
||||
|
||||
@ctx.action_class("user")
|
||||
class UserActions:
|
||||
def noise_trigger_pop():
|
||||
# Since zoom mouse is registering against noise.register("pop", on_pop), let that take priority
|
||||
if actions.tracking.control_zoom_enabled():
|
||||
return
|
||||
global time_last_pop
|
||||
delta = time.perf_counter() - time_last_pop
|
||||
double_pop_speed_minimum = settings.get("user.double_pop_speed_minimum")
|
||||
double_pop_speed_maximum = settings.get("user.double_pop_speed_maximum")
|
||||
if delta >= double_pop_speed_minimum and delta <= double_pop_speed_maximum:
|
||||
actions.core.repeat_command()
|
||||
time_last_pop = time.perf_counter()
|
||||
@@ -0,0 +1,8 @@
|
||||
# -1 because we are repeating, so the initial command counts as one
|
||||
<user.ordinals>: core.repeat_command(ordinals - 1)
|
||||
<number_small> times: core.repeat_command(number_small - 1)
|
||||
(repeat that | twice): core.repeat_command(1)
|
||||
repeat that <number_small> [times]: core.repeat_command(number_small)
|
||||
|
||||
(repeat phrase | again) [<number_small> times]:
|
||||
core.repeat_partial_phrase(number_small or 1)
|
||||
@@ -0,0 +1,149 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from talon import Context, Module, actions, app, clip, cron, screen, settings, ui
|
||||
from talon.canvas import Canvas
|
||||
|
||||
mod = Module()
|
||||
|
||||
mod.tag("screenshot_disabled", desc="Activating this tag disables screenshot commands")
|
||||
|
||||
default_folder = ""
|
||||
if app.platform == "windows":
|
||||
default_folder = os.path.expanduser(os.path.join("~", r"OneDrive\\Pictures"))
|
||||
if not os.path.isdir(default_folder):
|
||||
default_folder = os.path.join("~", "Pictures")
|
||||
|
||||
mod.setting(
|
||||
"screenshot_folder",
|
||||
type=str,
|
||||
default=default_folder,
|
||||
desc="Where to save screenshots. Note this folder must exist.",
|
||||
)
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def screenshot(screen_number: Optional[int] = None):
|
||||
"""Takes a screenshot of the entire screen and saves it to the pictures folder.
|
||||
Optional screen number can be given to use screen other than main."""
|
||||
selected_screen = get_screen(screen_number)
|
||||
actions.user.screenshot_rect(selected_screen.rect)
|
||||
|
||||
def screenshot_window():
|
||||
"""Takes a screenshot of the active window and saves it to the pictures folder"""
|
||||
win = ui.active_window()
|
||||
actions.user.screenshot_rect(win.rect, title=win.app.name)
|
||||
|
||||
def screenshot_selection():
|
||||
"""Triggers an application that is capable of taking a screenshot of a portion of the screen"""
|
||||
|
||||
def screenshot_selection_clip():
|
||||
"""Triggers an application that is capable of taking a screenshot of a portion of the screen and adding to clipboard"""
|
||||
|
||||
def screenshot_settings():
|
||||
"""Opens the settings UI for screenshots.
|
||||
Only applies to Mac for now
|
||||
"""
|
||||
if app.platform == "mac":
|
||||
actions.key("cmd-shift-5")
|
||||
else:
|
||||
app.notify("Not supported on this operating system")
|
||||
|
||||
def screenshot_clipboard(screen_number: Optional[int] = None):
|
||||
"""Takes a screenshot of the entire screen and saves it to the clipboard.
|
||||
Optional screen number can be given to use screen other than main."""
|
||||
selected_screen = get_screen(screen_number)
|
||||
clipboard_rect(selected_screen.rect)
|
||||
|
||||
def screenshot_window_clipboard():
|
||||
"""Takes a screenshot of the active window and saves it to the clipboard"""
|
||||
win = ui.active_window()
|
||||
clipboard_rect(win.rect)
|
||||
|
||||
def screenshot_rect(
|
||||
rect: ui.Rect, title: str = "", screen_num: Optional[int] = None
|
||||
):
|
||||
"""Allow other modules this screenshot a rectangle"""
|
||||
selected_screen = get_screen(screen_num)
|
||||
flash_rect(rect)
|
||||
img = screen.capture_rect(rect)
|
||||
path = get_screenshot_path(title)
|
||||
img.write_file(path)
|
||||
|
||||
|
||||
def clipboard_rect(rect: ui.Rect):
|
||||
flash_rect(rect)
|
||||
img = screen.capture_rect(rect)
|
||||
clip.set_image(img)
|
||||
|
||||
|
||||
def get_screenshot_path(title: str = ""):
|
||||
if title:
|
||||
title = f" - {title.replace('.', '_')}"
|
||||
date = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
||||
filename = f"Screenshot {date}{title}.png"
|
||||
folder_path = settings.get("user.screenshot_folder")
|
||||
path = os.path.expanduser(os.path.join(folder_path, filename))
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
def flash_rect(rect: ui.Rect):
|
||||
def on_draw(c):
|
||||
c.paint.style = c.paint.Style.FILL
|
||||
c.paint.color = "ffffff"
|
||||
c.draw_rect(rect)
|
||||
cron.after("150ms", canvas.close)
|
||||
|
||||
canvas = Canvas.from_rect(rect)
|
||||
canvas.register("draw", on_draw)
|
||||
canvas.freeze()
|
||||
|
||||
|
||||
def get_screen(screen_number: Optional[int] = None) -> ui.Screen:
|
||||
if screen_number is None:
|
||||
return screen.main_screen()
|
||||
return actions.user.screens_get_by_number(screen_number)
|
||||
|
||||
|
||||
ctx_mac = Context()
|
||||
ctx_mac.matches = r"""
|
||||
os: mac
|
||||
"""
|
||||
|
||||
|
||||
@ctx_mac.action_class("user")
|
||||
class UserActionsMac:
|
||||
def screenshot_selection():
|
||||
actions.key("cmd-shift-4")
|
||||
|
||||
def screenshot_selection_clip():
|
||||
actions.key("cmd-ctrl-shift-4")
|
||||
|
||||
|
||||
ctx_win = Context()
|
||||
ctx_win.matches = r"""
|
||||
os: windows
|
||||
"""
|
||||
|
||||
|
||||
@ctx_win.action_class("user")
|
||||
class UserActionsWin:
|
||||
def screenshot_selection():
|
||||
actions.key("super-shift-s")
|
||||
|
||||
def screenshot_selection_clip():
|
||||
actions.key("super-shift-s")
|
||||
|
||||
|
||||
ctx_linux = Context()
|
||||
ctx_linux.matches = r"""
|
||||
os: linux
|
||||
"""
|
||||
|
||||
|
||||
@ctx_linux.action_class("user")
|
||||
class UserActionsLinux:
|
||||
def screenshot_selection():
|
||||
actions.key("shift-printscr")
|
||||
@@ -0,0 +1,12 @@
|
||||
not tag: user.screenshot_disabled
|
||||
-
|
||||
|
||||
^grab screen$: user.screenshot()
|
||||
^grab screen <number_small>$: user.screenshot(number_small)
|
||||
^grab window$: user.screenshot_window()
|
||||
^grab selection$: user.screenshot_selection()
|
||||
^grab selection clip$: user.screenshot_selection_clip()
|
||||
^grab settings$: user.screenshot_settings()
|
||||
^grab screen clip$: user.screenshot_clipboard()
|
||||
^grab screen <number_small> clip$: user.screenshot_clipboard(number_small)
|
||||
^grab window clip$: user.screenshot_window_clipboard()
|
||||
@@ -0,0 +1,21 @@
|
||||
# Subtitles
|
||||
|
||||
Custom subtitles with settings to tweak color, position and timeout.
|
||||
|
||||
Talon's default subtitles needs to be disabled from the Talon menu to avoid duplicates.
|
||||
|
||||

|
||||
|
||||
## Show / hide subtitles
|
||||
|
||||
### Show subtitles
|
||||
|
||||
Setting: `user.subtitles_show = true`
|
||||
|
||||
### Hide subtitles
|
||||
|
||||
Setting: `user.subtitles_show = false`
|
||||
|
||||
## Other options
|
||||
|
||||
See additional configuration options documented in `subtitles.talon`.
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,29 @@
|
||||
from talon import actions, speech_system
|
||||
from talon.grammar import Phrase
|
||||
|
||||
from .subtitles import show_subtitle
|
||||
|
||||
|
||||
def on_pre_phrase(phrase: Phrase):
|
||||
if skip_phrase(phrase):
|
||||
return
|
||||
|
||||
words = phrase["phrase"]
|
||||
current_phrase = " ".join(words)
|
||||
show_subtitle(current_phrase)
|
||||
|
||||
|
||||
def skip_phrase(phrase: Phrase) -> bool:
|
||||
return not phrase.get("phrase") or skip_phrase_in_sleep(phrase)
|
||||
|
||||
|
||||
def skip_phrase_in_sleep(phrase: Phrase) -> bool:
|
||||
"""Returns true if the rule is <phrase> in sleep mode"""
|
||||
return (
|
||||
not actions.speech.enabled()
|
||||
and len(phrase["parsed"]) == 1
|
||||
and phrase["parsed"][0]._name == "___ltphrase_gt__"
|
||||
)
|
||||
|
||||
|
||||
speech_system.register("phrase", on_pre_phrase)
|
||||
@@ -0,0 +1,151 @@
|
||||
from typing import Any, Callable, Optional, Sequence, Type
|
||||
|
||||
from talon import Module, app, cron, ctrl, settings, ui
|
||||
from talon.canvas import Canvas
|
||||
from talon.skia.canvas import Canvas as SkiaCanvas
|
||||
from talon.skia.imagefilter import ImageFilter
|
||||
from talon.types import Rect
|
||||
|
||||
mod = Module()
|
||||
|
||||
|
||||
def setting(
|
||||
name: str, type: Type, desc: str, *, default: Optional[Any] = None
|
||||
) -> Callable[[], type]:
|
||||
mod.setting(f"subtitles_{name}", type, default=default, desc=f"Subtitles: {desc}")
|
||||
return lambda: settings.get(f"user.subtitles_{name}")
|
||||
|
||||
|
||||
setting_show = setting(
|
||||
"show",
|
||||
bool,
|
||||
"If true show (custom) subtitles",
|
||||
default=False,
|
||||
)
|
||||
setting_screens = setting(
|
||||
"screens",
|
||||
str,
|
||||
"Show on which screens: 'all', 'main', 'cursor', 'focus'",
|
||||
)
|
||||
setting_size = setting(
|
||||
"size",
|
||||
int,
|
||||
"Subtitle size in pixels",
|
||||
)
|
||||
setting_color = setting(
|
||||
"color",
|
||||
str,
|
||||
"Subtitle color",
|
||||
)
|
||||
setting_color_outline = setting(
|
||||
"color_outline",
|
||||
str,
|
||||
"Subtitle outline color",
|
||||
)
|
||||
setting_timeout_per_char = setting(
|
||||
"timeout_per_char",
|
||||
int,
|
||||
"For each character in the subtitle extend the timeout by this amount in ms",
|
||||
)
|
||||
setting_timeout_min = setting(
|
||||
"timeout_min",
|
||||
int,
|
||||
"Minimum time for a subtitle to show in ms",
|
||||
)
|
||||
setting_timeout_max = setting(
|
||||
"timeout_max",
|
||||
int,
|
||||
"Maximum time for a subtitle to show in ms",
|
||||
)
|
||||
setting_y = setting(
|
||||
"y",
|
||||
float,
|
||||
"Percentage of screen hight to show subtitle at. 0=top, 1=bottom",
|
||||
)
|
||||
|
||||
mod = Module()
|
||||
canvases: list[Canvas] = []
|
||||
|
||||
|
||||
def show_subtitle(text: str):
|
||||
"""Show subtitle"""
|
||||
if not setting_show():
|
||||
return
|
||||
clear_canvases()
|
||||
screens = get_screens()
|
||||
for screen in screens:
|
||||
canvas = show_text_on_screen(screen, text)
|
||||
canvases.append(canvas)
|
||||
|
||||
|
||||
def get_screens() -> Sequence[ui.Screen]:
|
||||
screen = setting_screens()
|
||||
match screen:
|
||||
case "main":
|
||||
return [ui.main_screen()]
|
||||
case "all":
|
||||
return ui.screens()
|
||||
case "cursor":
|
||||
x, y = ctrl.mouse_pos()
|
||||
return [ui.screen_containing(x, y)]
|
||||
case "focus":
|
||||
return [ui.active_window().screen]
|
||||
case _:
|
||||
raise ValueError(f"Unknown screen setting: {screen}")
|
||||
|
||||
|
||||
def show_text_on_screen(screen: ui.Screen, text: str):
|
||||
timeout = calculate_timeout(text)
|
||||
canvas = Canvas.from_screen(screen)
|
||||
canvas.register("draw", lambda c: on_draw(c, screen, text))
|
||||
canvas.freeze()
|
||||
cron.after(f"{timeout}ms", canvas.close)
|
||||
return canvas
|
||||
|
||||
|
||||
def on_draw(c: SkiaCanvas, screen: ui.Screen, text: str):
|
||||
scale = screen.scale if app.platform != "mac" else 1
|
||||
size = setting_size() * scale
|
||||
rect = set_text_size_and_get_rect(c, size, text)
|
||||
x = c.rect.center.x - rect.center.x
|
||||
# Clamp coordinate to make sure entire text is visible
|
||||
y = max(
|
||||
min(
|
||||
c.rect.y + setting_y() * c.rect.height + c.paint.textsize / 2,
|
||||
c.rect.bot - rect.bot,
|
||||
),
|
||||
c.rect.top - rect.top,
|
||||
)
|
||||
|
||||
c.paint.imagefilter = ImageFilter.drop_shadow(2, 2, 1, 1, "000000")
|
||||
c.paint.style = c.paint.Style.FILL
|
||||
c.paint.color = setting_color()
|
||||
c.draw_text(text, x, y)
|
||||
|
||||
# Outline
|
||||
c.paint.imagefilter = None
|
||||
c.paint.style = c.paint.Style.STROKE
|
||||
c.paint.color = setting_color_outline()
|
||||
c.draw_text(text, x, y)
|
||||
|
||||
|
||||
def calculate_timeout(text: str) -> int:
|
||||
ms_per_char = setting_timeout_per_char()
|
||||
ms_min = setting_timeout_min()
|
||||
ms_max = setting_timeout_max()
|
||||
return min(ms_max, max(ms_min, len(text) * ms_per_char))
|
||||
|
||||
|
||||
def set_text_size_and_get_rect(c: SkiaCanvas, size: int, text: str) -> Rect:
|
||||
while True:
|
||||
c.paint.textsize = size
|
||||
rect = c.paint.measure_text(text)[1]
|
||||
if rect.width < c.width * 0.8:
|
||||
return rect
|
||||
size *= 0.9
|
||||
|
||||
|
||||
def clear_canvases():
|
||||
for canvas in canvases:
|
||||
canvas.close()
|
||||
canvases.clear()
|
||||
@@ -0,0 +1,23 @@
|
||||
settings():
|
||||
# Show subtitles?
|
||||
user.subtitles_show = false
|
||||
# Screens on which to show subtitles:
|
||||
# "all" - all screens
|
||||
# "main" - main screen as configured in OS
|
||||
# "cursor" - screen containing mouse pointer
|
||||
# "focus" - screen containing active/focused window/app
|
||||
user.subtitles_screens = "main"
|
||||
# 100 px maximum subtitle font size
|
||||
user.subtitles_size = 100
|
||||
# White subtitle color
|
||||
user.subtitles_color = "ffffff"
|
||||
# Slightly dark subtitle outline
|
||||
user.subtitles_color_outline = "aaaaaa"
|
||||
# For each character in the subtitle, extend the timeout 50 ms
|
||||
user.subtitles_timeout_per_char = 50
|
||||
# 750 ms is the minimum time to display a subtitle
|
||||
user.subtitles_timeout_min = 750
|
||||
# 3 seconds is the maximum time to display a subtitle
|
||||
user.subtitles_timeout_max = 3000
|
||||
# Position subtitles at the bottom of the screen (93% from top)
|
||||
user.subtitles_y = 0.93
|
||||
@@ -0,0 +1,15 @@
|
||||
new line: "\n"
|
||||
double dash: "--"
|
||||
triple quote: "'''"
|
||||
triple grave | triple back tick | gravy: "```"
|
||||
(dot dot | dotdot): ".."
|
||||
ellipsis: "..."
|
||||
(comma and | spamma): ", "
|
||||
arrow: "->"
|
||||
dub arrow: "=>"
|
||||
|
||||
# Insert delimiter pairs
|
||||
<user.delimiter_pair>: user.delimiter_pair_insert(delimiter_pair)
|
||||
|
||||
# Wrap selection with delimiter pairs
|
||||
<user.delimiter_pair> that: user.delimiter_pair_wrap_selection(delimiter_pair)
|
||||
@@ -0,0 +1,83 @@
|
||||
empty dub string:
|
||||
user.deprecate_command("2024-11-24", "empty dub string", "quad")
|
||||
user.insert_between('"', '"')
|
||||
|
||||
empty escaped (dub string | dub quotes):
|
||||
user.deprecate_command("2024-11-24", "empty escaped (dub string | dub quotes)", "escaped quad")
|
||||
user.insert_between('\\"', '\\"')
|
||||
|
||||
empty string:
|
||||
user.deprecate_command("2024-11-24", "empty string", "twin")
|
||||
user.insert_between("'", "'")
|
||||
|
||||
empty escaped string:
|
||||
user.deprecate_command("2024-11-24", "empty escaped string", "escaped twin")
|
||||
user.insert_between("\\'", "\\'")
|
||||
|
||||
inside (parens | args):
|
||||
user.deprecate_command("2024-11-24", "inside (parens | args)", "round")
|
||||
user.insert_between("(", ")")
|
||||
|
||||
inside (squares | brackets | square brackets | list):
|
||||
user.deprecate_command("2024-11-24", "inside (squares | brackets | square brackets | list)", "box")
|
||||
user.insert_between("[", "]")
|
||||
|
||||
inside (braces | curly brackets):
|
||||
user.deprecate_command("2024-11-24", "inside (braces | curly brackets)", "curly")
|
||||
user.insert_between("{", "}")
|
||||
|
||||
inside percent:
|
||||
user.deprecate_command("2024-11-24", "inside percent", "percentages")
|
||||
user.insert_between("%", "%")
|
||||
|
||||
inside (quotes | string):
|
||||
user.deprecate_command("2024-11-24", "inside (quotes | string)", "twin")
|
||||
user.insert_between("'", "'")
|
||||
|
||||
inside (double quotes | dub quotes):
|
||||
user.deprecate_command("2024-11-24", "inside (double quotes | dub quotes)", "quad")
|
||||
user.insert_between('"', '"')
|
||||
|
||||
inside (graves | back ticks):
|
||||
user.deprecate_command("2024-11-24", "inside (graves | back ticks)", "skis")
|
||||
user.insert_between("`", "`")
|
||||
|
||||
angle that:
|
||||
user.deprecate_command("2024-11-24", "angle that", "diamond that")
|
||||
text = edit.selected_text()
|
||||
user.paste("<{text}>")
|
||||
|
||||
(square | bracket | square bracket) that:
|
||||
user.deprecate_command("2024-11-24", "(square | bracket | square bracket) that", "box that")
|
||||
text = edit.selected_text()
|
||||
user.paste("[{text}]")
|
||||
|
||||
(brace | curly bracket) that:
|
||||
user.deprecate_command("2024-11-24", "(brace | curly bracket) that", "curly that")
|
||||
text = edit.selected_text()
|
||||
user.paste("{{{text}}}")
|
||||
|
||||
(parens | args) that:
|
||||
user.deprecate_command("2024-11-24", "(parens | args) that", "round that")
|
||||
text = edit.selected_text()
|
||||
user.paste("({text})")
|
||||
|
||||
percent that:
|
||||
user.deprecate_command("2024-11-24", "percent that", "percentages that")
|
||||
text = edit.selected_text()
|
||||
user.paste("%{text}%")
|
||||
|
||||
quote that:
|
||||
user.deprecate_command("2024-11-24", "quote that", "twin that")
|
||||
text = edit.selected_text()
|
||||
user.paste("'{text}'")
|
||||
|
||||
(double quote | dub quote) that:
|
||||
user.deprecate_command("2024-11-24", "(double quote | dub quote) that", "quad that")
|
||||
text = edit.selected_text()
|
||||
user.paste('"{text}"')
|
||||
|
||||
(grave | back tick) that:
|
||||
user.deprecate_command("2024-11-24", "(grave | back tick) that", "skis that")
|
||||
text = edit.selected_text()
|
||||
user.paste("`{text}`")
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Stefan Schneider
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
The draft window allows you to more easily edit prose style text via a task-specific UI.
|
||||
|
||||
# Usage
|
||||
|
||||
The main idea is that we have a Talon controlled text area where each word is labelled with a letter (called an anchor). You can use the anchors to indicate which word you want to operate on.
|
||||
|
||||
An session might go like this for example:
|
||||
|
||||
# Start with the text "this is a sentence with an elephant." in your editor or other textbox
|
||||
draft edit all # Select all the text in your editor and moves it to the draft window
|
||||
replace gust with error # Replaces the word corresponding with the red anchor 'g' (gust in community) with the word 'error'
|
||||
period # Add a full stop
|
||||
select each through fine # Select the words starting at the 'e' anchor and ending at 'f'
|
||||
say without # Insert the word 'without' (community)
|
||||
title word air # Make the word corresponding to the 'a' anchor capitalised
|
||||
draft submit # Type the text in your draft window back into your editor
|
||||
# End with the text "This is a sentence without error." in your editor or other textbox
|
||||
|
||||
Here's a video of me going through the above commands:
|
||||
|
||||

|
||||
|
||||
# Customising
|
||||
|
||||
If you want to change the display of the window you can do by adding some settings to one of your .talon files. See `settings.talon.example` for more details.
|
||||
|
||||
# Running tests
|
||||
|
||||
There are unit tests that you can run from the repository root like this (assuming your directory is called talon_draft_window):
|
||||
|
||||
(cd ../ && python -m unittest talon_draft_window.test_draft_ui)
|
||||
|
||||
The reason for the weirdness is because we have everything in the same directory and are doing relative imports.
|
||||
@@ -0,0 +1 @@
|
||||
# Used so we can use pytest to run this sub-package's tests
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,38 @@
|
||||
# These are available globally (in command mode)
|
||||
mode: command
|
||||
-
|
||||
^draft show:
|
||||
# Do this toggle so we can have focus when saying 'draft show'
|
||||
user.draft_hide()
|
||||
user.draft_show()
|
||||
|
||||
^draft show <user.draft_window_position>:
|
||||
# Do this toggle so we can have focus when saying 'draft show'
|
||||
user.draft_hide()
|
||||
user.draft_show()
|
||||
user.draft_named_move(draft_window_position)
|
||||
|
||||
^draft show small:
|
||||
# Do this toggle so we can have focus when saying 'draft show'
|
||||
user.draft_hide()
|
||||
user.draft_show()
|
||||
user.draft_resize(600, 200)
|
||||
|
||||
^draft show large:
|
||||
# Do this toggle so we can have focus when saying 'draft show'
|
||||
user.draft_hide()
|
||||
user.draft_show()
|
||||
user.draft_resize(800, 500)
|
||||
|
||||
^draft empty: user.draft_show("")
|
||||
|
||||
^draft edit:
|
||||
text = edit.selected_text()
|
||||
key(backspace)
|
||||
user.draft_show(text)
|
||||
|
||||
^draft edit all:
|
||||
edit.select_all()
|
||||
text = edit.selected_text()
|
||||
key(backspace)
|
||||
user.draft_show(text)
|
||||
@@ -0,0 +1,322 @@
|
||||
from typing import Optional
|
||||
|
||||
from talon import Context, Module, actions, settings, ui
|
||||
|
||||
from .draft_ui import DraftManager
|
||||
|
||||
mod = Module()
|
||||
|
||||
# ctx is for toggling the draft_window_showing variable
|
||||
# which lets you execute actions whenever the window is visible.
|
||||
ctx = Context()
|
||||
|
||||
# ctx_focused is active only when the draft window is focussed. This
|
||||
# lets you execute actions under that condition.
|
||||
ctx_focused = Context()
|
||||
ctx_focused.matches = r"""
|
||||
title: Talon Draft
|
||||
"""
|
||||
|
||||
mod.tag("draft_window_showing", desc="Tag set when draft window showing")
|
||||
mod.setting(
|
||||
"draft_window_theme",
|
||||
type=str,
|
||||
default="dark",
|
||||
desc="Sets the main colors of the window, one of 'dark' or 'light'",
|
||||
)
|
||||
mod.setting(
|
||||
"draft_window_label_size",
|
||||
type=int,
|
||||
default=20,
|
||||
desc="Sets the size of the word labels used in the draft window",
|
||||
)
|
||||
mod.setting(
|
||||
"draft_window_label_color",
|
||||
type=str,
|
||||
default=None,
|
||||
desc=(
|
||||
"Sets the color of the word labels used in the draft window. "
|
||||
"E.g. 00ff00 would be green"
|
||||
),
|
||||
)
|
||||
mod.setting(
|
||||
"draft_window_text_size",
|
||||
type=int,
|
||||
default=20,
|
||||
desc="Sets the size of the text used in the draft window",
|
||||
)
|
||||
|
||||
|
||||
draft_manager = DraftManager()
|
||||
|
||||
|
||||
# Update the styling of the draft window dynamically as user settings change
|
||||
def _update_draft_style(*args):
|
||||
draft_manager.set_styling(
|
||||
**{
|
||||
arg: settings.get(setting)
|
||||
for setting, arg in (
|
||||
("user.draft_window_theme", "theme"),
|
||||
("user.draft_window_label_size", "label_size"),
|
||||
("user.draft_window_label_color", "label_color"),
|
||||
("user.draft_window_text_size", "text_size"),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
settings.register("", _update_draft_style)
|
||||
|
||||
|
||||
@ctx_focused.action_class("user")
|
||||
class ContextSensitiveDictationActions:
|
||||
"""
|
||||
Override these actions to assist 'Smart dictation mode'.
|
||||
see https://github.com/talonhub/community/pull/356
|
||||
"""
|
||||
|
||||
def dictation_peek(left, right):
|
||||
area = draft_manager.area
|
||||
return (
|
||||
area[max(0, area.sel.left - 50) : area.sel.left],
|
||||
area[area.sel.right : area.sel.right + 50],
|
||||
)
|
||||
|
||||
def paste(text: str):
|
||||
# todo: remove once user.paste works reliably with the draft window
|
||||
actions.insert(text)
|
||||
|
||||
|
||||
@ctx_focused.action_class("edit")
|
||||
class EditActions:
|
||||
"""
|
||||
Make default edit actions more efficient.
|
||||
"""
|
||||
|
||||
def selected_text() -> str:
|
||||
area = draft_manager.area
|
||||
if area.sel:
|
||||
result = area[area.sel.left : area.sel.right]
|
||||
return result
|
||||
return ""
|
||||
|
||||
|
||||
from talon import cron
|
||||
|
||||
|
||||
class UndoWorkaround:
|
||||
"""
|
||||
Workaround for the experimental textarea's undo being character by character.
|
||||
This keeps a debounced undo history. Can be deleted once this todo item is
|
||||
fixed: https://github.com/talonvoice/talon/issues/254#issuecomment-789149734
|
||||
"""
|
||||
|
||||
# Set this to False if you want to turn it off, or just delete all references
|
||||
# to this class
|
||||
enable_workaround = True
|
||||
|
||||
# Stack of (text_value, selection) tuples representing the undo stack
|
||||
undo_stack = []
|
||||
# Stack of (text_value, selection) tuples representing the redo stack
|
||||
redo_stack = []
|
||||
# Used by the timer to check when the text has stopped changing
|
||||
pending_undo = None
|
||||
|
||||
# timer handle
|
||||
timer_handle = None
|
||||
|
||||
@classmethod
|
||||
def start_logger(cls, reset_undo_stack: bool):
|
||||
if reset_undo_stack:
|
||||
cls.undo_stack = []
|
||||
cls.redo_stack = []
|
||||
|
||||
cls.stop_logger()
|
||||
cls.timer_handle = cron.interval("500ms", cls._log_changes)
|
||||
|
||||
@classmethod
|
||||
def stop_logger(cls):
|
||||
if cls.timer_handle is not None:
|
||||
cron.cancel(cls.timer_handle)
|
||||
cls.timer_handle = None
|
||||
cls.pending_undo = None
|
||||
|
||||
@classmethod
|
||||
def perform_undo(cls):
|
||||
if len(cls.undo_stack) == 0:
|
||||
return
|
||||
|
||||
curr_text = draft_manager.area.value
|
||||
curr_sel = (draft_manager.area.sel.left, draft_manager.area.sel.right)
|
||||
text, sel = cls.undo_stack[-1]
|
||||
if text == curr_text:
|
||||
cls.undo_stack.pop()
|
||||
if len(cls.undo_stack) == 0:
|
||||
return
|
||||
|
||||
# Most of the time (unless user has only just finished updating) the
|
||||
# top of the stack will have the same contents as the text area. In
|
||||
# this case pop again to get a bit lower. We should never have the
|
||||
# same text twice, hence we don't need a loop.
|
||||
text, sel = cls.undo_stack[-1]
|
||||
|
||||
# Remember the current state in the redo stack
|
||||
cls.redo_stack.append((curr_text, curr_sel))
|
||||
draft_manager.area.value = text
|
||||
draft_manager.area.sel = sel
|
||||
|
||||
cls.pending_undo = (text, sel)
|
||||
|
||||
@classmethod
|
||||
def perform_redo(cls):
|
||||
if len(cls.redo_stack) == 0:
|
||||
return
|
||||
|
||||
text, sel = cls.redo_stack.pop()
|
||||
|
||||
draft_manager.area.value = text
|
||||
draft_manager.area.sel = sel
|
||||
|
||||
cls.pending_undo = (text, sel)
|
||||
cls.undo_stack.append((text, sel))
|
||||
|
||||
@classmethod
|
||||
def _log_changes(cls):
|
||||
"""
|
||||
If the text and cursor position hasn't changed for two interval iterations
|
||||
(1s) and the undo stack doesn't match the current state, then add to the stack.
|
||||
"""
|
||||
|
||||
curr_val = draft_manager.area.value
|
||||
# Turn the Span into a tuple, because we can't == Spans
|
||||
curr_sel = (draft_manager.area.sel.left, draft_manager.area.sel.right)
|
||||
curr_state = (curr_val, curr_sel)
|
||||
|
||||
state_stack_mismatch = (
|
||||
len(cls.undo_stack) == 0
|
||||
or
|
||||
# Only want to update the undo stack if the value has changed, not just
|
||||
# the selection
|
||||
curr_state[0] != cls.undo_stack[-1][0]
|
||||
)
|
||||
|
||||
if cls.pending_undo == curr_state and state_stack_mismatch:
|
||||
cls.undo_stack.append(curr_state)
|
||||
# Clear out the redo stack because we've changed the text
|
||||
cls.redo_stack = []
|
||||
elif cls.pending_undo != curr_state:
|
||||
cls.pending_undo = curr_state
|
||||
elif not state_stack_mismatch and len(cls.undo_stack) > 0:
|
||||
# Remember the cursor position in the undo stack for the current text value
|
||||
cls.undo_stack[-1] = (cls.undo_stack[-1][0], curr_sel)
|
||||
else:
|
||||
# The text area text is not changing, do nothing
|
||||
pass
|
||||
|
||||
|
||||
if UndoWorkaround.enable_workaround:
|
||||
ctx_focused.action("edit.undo")(UndoWorkaround.perform_undo)
|
||||
ctx_focused.action("edit.redo")(UndoWorkaround.perform_redo)
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def draft_show(text: Optional[str] = None):
|
||||
"""
|
||||
Shows draft window
|
||||
"""
|
||||
|
||||
draft_manager.show(text)
|
||||
UndoWorkaround.start_logger(text is not None)
|
||||
ctx.tags = ["user.draft_window_showing"]
|
||||
|
||||
def draft_hide():
|
||||
"""
|
||||
Hides draft window
|
||||
"""
|
||||
|
||||
draft_manager.hide()
|
||||
UndoWorkaround.stop_logger()
|
||||
ctx.tags = []
|
||||
|
||||
def draft_select(
|
||||
start_anchor: str, end_anchor: str = "", include_trailing_whitespace: int = 0
|
||||
):
|
||||
"""
|
||||
Selects text in the draft window
|
||||
"""
|
||||
|
||||
draft_manager.select_text(
|
||||
start_anchor,
|
||||
end_anchor=None if end_anchor == "" else end_anchor,
|
||||
include_trailing_whitespace=include_trailing_whitespace == 1,
|
||||
)
|
||||
|
||||
def draft_position_caret(anchor: str, after: int = 0):
|
||||
"""
|
||||
Positions the caret in the draft window
|
||||
"""
|
||||
|
||||
draft_manager.position_caret(anchor, after=after == 1)
|
||||
|
||||
def draft_get_text() -> str:
|
||||
"""
|
||||
Returns the text in the draft window
|
||||
"""
|
||||
|
||||
return draft_manager.get_text()
|
||||
|
||||
def draft_resize(width: int, height: int):
|
||||
"""
|
||||
Resize the draft window.
|
||||
"""
|
||||
|
||||
draft_manager.reposition(width=width, height=height)
|
||||
|
||||
def draft_named_move(name: str, screen_number: Optional[int] = None):
|
||||
"""
|
||||
Lets you move the window to the top, bottom, left, right, or middle
|
||||
of the screen.
|
||||
"""
|
||||
|
||||
screen = ui.screens()[screen_number or 0]
|
||||
window_rect = draft_manager.get_rect()
|
||||
xpos = (screen.width - window_rect.width) / 2
|
||||
ypos = (screen.height - window_rect.height) / 2
|
||||
|
||||
if name == "top":
|
||||
ypos = 50
|
||||
elif name == "bottom":
|
||||
ypos = screen.height - window_rect.height - 50
|
||||
elif name == "left":
|
||||
xpos = 50
|
||||
elif name == "right":
|
||||
xpos = screen.width - window_rect.width - 50
|
||||
elif name == "middle":
|
||||
# That's the default values
|
||||
pass
|
||||
|
||||
# Adjust for the fact that the screen may not be at 0,0.
|
||||
xpos += screen.x
|
||||
ypos += screen.y
|
||||
draft_manager.reposition(xpos=xpos, ypos=ypos)
|
||||
|
||||
|
||||
# Some capture groups we need
|
||||
|
||||
|
||||
@mod.capture(rule="{self.letter}+")
|
||||
def draft_anchor(m) -> str:
|
||||
"""
|
||||
An anchor (string of letters)
|
||||
"""
|
||||
return "".join(m)
|
||||
|
||||
|
||||
@mod.capture(rule="(top|bottom|left|right|middle)")
|
||||
def draft_window_position(m) -> str:
|
||||
"""
|
||||
One of the named positions you can move the window to
|
||||
"""
|
||||
|
||||
return "".join(m)
|
||||
@@ -0,0 +1,203 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from talon.experimental.textarea import (
|
||||
DarkThemeLabels,
|
||||
LightThemeLabels,
|
||||
Span,
|
||||
TextArea,
|
||||
)
|
||||
|
||||
word_matcher = re.compile(r"([^\s]+)(\s*)")
|
||||
|
||||
|
||||
def calculate_text_anchors(text, cursor_position, anchor_labels=None):
|
||||
"""
|
||||
Produces an iterator of (anchor, start_word_index, end_word_index, last_space_index)
|
||||
tuples from the given text. Each tuple indicates a particular point you may want to
|
||||
reference when editing along with some useful ranges you may want to operate on.
|
||||
|
||||
- text is the text you want to process.
|
||||
- cursor_position is the current position of the cursor, anchors will be placed around
|
||||
this.
|
||||
- anchor_labels is a list of characters you want to use for your labels.
|
||||
- *index is just a character offset from the start of the string (e.g. the first character is at index 0)
|
||||
- end_word_index is the index of the character after the last one included in the
|
||||
anchor. That is, you can use it with a slice directly like [start:end]
|
||||
- anchor is a short piece of text you can use to identify it (e.g. 'a', or '1').
|
||||
"""
|
||||
anchor_labels = anchor_labels or "abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
if len(text) == 0:
|
||||
return []
|
||||
|
||||
# Find all the word spans
|
||||
matches = []
|
||||
cursor_idx = None
|
||||
for match in word_matcher.finditer(text):
|
||||
matches.append((match.start(), match.end() - len(match.group(2)), match.end()))
|
||||
if matches[-1][0] <= cursor_position and matches[-1][2] >= cursor_position:
|
||||
cursor_idx = len(matches) - 1
|
||||
|
||||
# Now work out what range of those matches are getting an anchor. The aim is
|
||||
# to centre the anchors around the cursor position, but also to use all the
|
||||
# anchors.
|
||||
anchors_before_cursor = len(anchor_labels) // 2
|
||||
anchor_start_idx = max(0, cursor_idx - anchors_before_cursor)
|
||||
anchor_end_idx = min(len(matches), anchor_start_idx + len(anchor_labels))
|
||||
anchor_start_idx = max(0, anchor_end_idx - len(anchor_labels))
|
||||
|
||||
# Now add anchors to the selected matches
|
||||
for i, anchor in zip(range(anchor_start_idx, anchor_end_idx), anchor_labels):
|
||||
word_start, word_end, whitespace_end = matches[i]
|
||||
yield (anchor, word_start, word_end, whitespace_end)
|
||||
|
||||
|
||||
class DraftManager:
|
||||
"""
|
||||
API to the draft window
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.area = TextArea()
|
||||
self.area.title = "Talon Draft"
|
||||
self.area.value = ""
|
||||
self.area.register("label", self._update_labels)
|
||||
self.set_styling()
|
||||
|
||||
def set_styling(self, theme="dark", text_size=20, label_size=20, label_color=None):
|
||||
"""
|
||||
Allow settings the style of the draft window. Will dynamically
|
||||
update the style based on the passed in parameters.
|
||||
"""
|
||||
|
||||
area_theme = DarkThemeLabels if theme == "dark" else LightThemeLabels
|
||||
theme_changes = {
|
||||
"text_size": text_size,
|
||||
"label_size": label_size,
|
||||
}
|
||||
if label_color is not None:
|
||||
theme_changes["label"] = label_color
|
||||
self.area.theme = area_theme(**theme_changes)
|
||||
|
||||
def show(self, text: Optional[str] = None):
|
||||
"""
|
||||
Show the window. If text is None then keep the old contents,
|
||||
otherwise set the text to the given value.
|
||||
"""
|
||||
|
||||
if text is not None:
|
||||
self.area.value = text
|
||||
self.area.show()
|
||||
|
||||
def hide(self):
|
||||
"""
|
||||
Hide the window.
|
||||
"""
|
||||
|
||||
self.area.hide()
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
Gets the context of the text area
|
||||
"""
|
||||
|
||||
return self.area.value
|
||||
|
||||
def get_rect(self) -> "talon.types.Rect":
|
||||
"""
|
||||
Get the Rect for the window
|
||||
"""
|
||||
|
||||
return self.area.rect
|
||||
|
||||
def reposition(
|
||||
self,
|
||||
xpos: Optional[int] = None,
|
||||
ypos: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Move the window or resize it without having to change all properties.
|
||||
"""
|
||||
|
||||
rect = self.area.rect
|
||||
if xpos is not None:
|
||||
rect.x = xpos
|
||||
|
||||
if ypos is not None:
|
||||
rect.y = ypos
|
||||
|
||||
if width is not None:
|
||||
rect.width = width
|
||||
|
||||
if height is not None:
|
||||
rect.height = height
|
||||
|
||||
self.area.rect = rect
|
||||
|
||||
def select_text(
|
||||
self, start_anchor, end_anchor=None, include_trailing_whitespace=False
|
||||
):
|
||||
"""
|
||||
Selects the word corresponding to start_anchor. If end_anchor supplied, selects
|
||||
from start_anchor to the end of end_anchor. If include_trailing_whitespace=True
|
||||
then also selects trailing space characters (useful for delete).
|
||||
"""
|
||||
|
||||
start_index, end_index, last_space_index = self.anchor_to_range(start_anchor)
|
||||
if end_anchor is not None:
|
||||
_, end_index, last_space_index = self.anchor_to_range(end_anchor)
|
||||
|
||||
if include_trailing_whitespace:
|
||||
end_index = last_space_index
|
||||
|
||||
self.area.sel = Span(start_index, end_index)
|
||||
|
||||
def position_caret(self, anchor, after=False):
|
||||
"""
|
||||
Positions the caret before the given anchor. If after=True position it directly after.
|
||||
"""
|
||||
|
||||
start_index, end_index, _ = self.anchor_to_range(anchor)
|
||||
index = end_index if after else start_index
|
||||
|
||||
self.area.sel = index
|
||||
|
||||
def anchor_to_range(self, anchor):
|
||||
anchors_data = calculate_text_anchors(
|
||||
self._get_visible_text(), self.area.sel.left
|
||||
)
|
||||
for loop_anchor, start_index, end_index, last_space_index in anchors_data:
|
||||
if anchor == loop_anchor:
|
||||
return (start_index, end_index, last_space_index)
|
||||
|
||||
raise RuntimeError(f"Couldn't find anchor {anchor}")
|
||||
|
||||
def _update_labels(self, _visible_text):
|
||||
"""
|
||||
Updates the position of the labels displayed on top of each word
|
||||
"""
|
||||
|
||||
anchors_data = calculate_text_anchors(
|
||||
self._get_visible_text(), self.area.sel.left
|
||||
)
|
||||
return [
|
||||
(Span(start_index, end_index), anchor)
|
||||
for anchor, start_index, end_index, _ in anchors_data
|
||||
]
|
||||
|
||||
def _get_visible_text(self):
|
||||
# Placeholder for a future method of getting this
|
||||
return self.area.value
|
||||
|
||||
|
||||
if False:
|
||||
# Some code for testing, change above False to True and edit as desired
|
||||
draft_manager = DraftManager()
|
||||
draft_manager.show(
|
||||
"This is a line of text\nand another line of text and some more text so that the line gets so long that it wraps a bit.\nAnd a final sentence"
|
||||
)
|
||||
draft_manager.reposition(xpos=100, ypos=100)
|
||||
draft_manager.select_text("c")
|
||||
@@ -0,0 +1,47 @@
|
||||
# These are active when we have focus on the draft window
|
||||
title: Talon Draft
|
||||
-
|
||||
settings():
|
||||
# Enable 'Smart dictation mode', see https://github.com/talonhub/community/pull/356
|
||||
user.context_sensitive_dictation = true
|
||||
|
||||
# Replace a single word with a phrase
|
||||
replace <user.draft_anchor> with <user.text>:
|
||||
user.draft_select("{draft_anchor}")
|
||||
result = user.formatted_text(text, "NOOP")
|
||||
insert(result)
|
||||
|
||||
# Position cursor before word
|
||||
(pre | cursor | cursor before) <user.draft_anchor>:
|
||||
user.draft_position_caret("{draft_anchor}")
|
||||
|
||||
# Position cursor after word
|
||||
(post | cursor after) <user.draft_anchor>:
|
||||
user.draft_position_caret("{draft_anchor}", 1)
|
||||
|
||||
# Select a whole word
|
||||
(take | select) <user.draft_anchor>: user.draft_select("{draft_anchor}")
|
||||
|
||||
# Select a range of words
|
||||
(take | select) <user.draft_anchor> (through | past) <user.draft_anchor>:
|
||||
user.draft_select("{draft_anchor_1}", "{draft_anchor_2}")
|
||||
|
||||
# Delete a word
|
||||
(change | chuck | clear) <user.draft_anchor>:
|
||||
user.draft_select("{draft_anchor}", "", 1)
|
||||
key(backspace)
|
||||
|
||||
# Delete a range of words
|
||||
(change | chuck | clear) <user.draft_anchor> (through | past) <user.draft_anchor>:
|
||||
user.draft_select(draft_anchor_1, draft_anchor_2, 1)
|
||||
key(backspace)
|
||||
|
||||
# reformat word
|
||||
<user.formatters> word <user.draft_anchor>:
|
||||
user.draft_select("{draft_anchor}", "", 1)
|
||||
user.formatters_reformat_selection(user.formatters)
|
||||
|
||||
# reformat range
|
||||
<user.formatters> <user.draft_anchor> (through | past) <user.draft_anchor>:
|
||||
user.draft_select(draft_anchor_1, draft_anchor_2, 1)
|
||||
user.formatters_reformat_selection(user.formatters)
|
||||
@@ -0,0 +1,12 @@
|
||||
# These are available when the draft window is open, but not necessarily focussed
|
||||
tag: user.draft_window_showing
|
||||
-
|
||||
draft hide: user.draft_hide()
|
||||
|
||||
draft submit:
|
||||
content = user.draft_get_text()
|
||||
user.draft_hide()
|
||||
insert(content)
|
||||
# user.paste may be somewhat faster, but seems to be unreliable on MacOSX, see
|
||||
# https://github.com/talonvoice/talon/issues/254#issuecomment-789355238
|
||||
# user.paste(content)
|
||||
@@ -0,0 +1,8 @@
|
||||
# Put some settings like this in one of your Talon files to override the styling
|
||||
# of the draft window.
|
||||
-
|
||||
settings():
|
||||
user.draft_window_theme = "dark" # or light
|
||||
user.draft_window_text_size = 20
|
||||
user.draft_window_label_size = 20
|
||||
user.draft_window_label_color = "ff0000" # Any hex code RGB value, e.g. this is red
|
||||
@@ -0,0 +1,56 @@
|
||||
import talon
|
||||
|
||||
if hasattr(talon, "test_mode"):
|
||||
# Only include this when we're running tests
|
||||
|
||||
pass
|
||||
|
||||
from .draft_ui import calculate_text_anchors
|
||||
|
||||
def test_finds_anchors():
|
||||
examples = [
|
||||
("one-word", [("a", 0, 8, 8)]),
|
||||
("two words", [("a", 0, 3, 4), ("b", 4, 9, 9)]),
|
||||
("two\nwords", [("a", 0, 3, 4), ("b", 4, 9, 9)]),
|
||||
]
|
||||
anchor_labels = ["a", "b"]
|
||||
for text, expected in examples:
|
||||
# Given an example
|
||||
|
||||
# When we calculate the result and turn it into a list
|
||||
result = list(calculate_text_anchors(text, 0, anchor_labels=anchor_labels))
|
||||
|
||||
# Then it matches what we expect
|
||||
assert result == expected, text
|
||||
|
||||
def test_positions_anchors_around_cursor():
|
||||
# In these examples the cursor is at the asterisk which is stripped by the test
|
||||
# code. Indicies after the asterisk have to take this into account.
|
||||
examples = [
|
||||
("one*-word", [("a", 0, 8, 8)]),
|
||||
("one-word*", [("a", 0, 8, 8)]),
|
||||
("the three words*", [("a", 0, 3, 4), ("b", 4, 9, 10), ("c", 10, 15, 15)]),
|
||||
("*the three words", [("a", 0, 3, 4), ("b", 4, 9, 10), ("c", 10, 15, 15)]),
|
||||
(
|
||||
"too many* words for the number of anchors",
|
||||
[("a", 0, 3, 4), ("b", 4, 8, 9), ("c", 9, 14, 15)],
|
||||
),
|
||||
(
|
||||
"too many words fo*r the number of anchors",
|
||||
[("a", 9, 14, 15), ("b", 15, 18, 19), ("c", 19, 22, 23)],
|
||||
),
|
||||
]
|
||||
anchor_labels = ["a", "b", "c"]
|
||||
|
||||
for text_with_cursor, expected in examples:
|
||||
# Given an example
|
||||
cursor_pos = text_with_cursor.index("*")
|
||||
text = text_with_cursor.replace("*", "")
|
||||
|
||||
# When we calculate the result and turn it into a list
|
||||
result = list(
|
||||
calculate_text_anchors(text, cursor_pos, anchor_labels=anchor_labels)
|
||||
)
|
||||
|
||||
# Then it matches what we expect
|
||||
assert result == expected, text
|
||||
@@ -0,0 +1,99 @@
|
||||
import os
|
||||
import re
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
|
||||
from talon import Module, actions, app, ui
|
||||
|
||||
APPS_DIR = Path(__file__).parent.parent.parent / "apps"
|
||||
|
||||
mod = Module()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def talon_create_app_context(platform_suffix: str = None):
|
||||
"""Create a new directory with talon and python context files for the current application"""
|
||||
active_app = ui.active_app()
|
||||
app_name = get_app_name(active_app.name)
|
||||
app_dir = APPS_DIR / app_name
|
||||
talon_file = app_dir / f"{app_name}.talon"
|
||||
python_file = app_dir / f"{get_platform_filename(app_name, platform_suffix)}.py"
|
||||
|
||||
talon_context = get_talon_context(app_name)
|
||||
python_context = get_python_context(active_app, app_name)
|
||||
|
||||
if not app_dir.is_dir():
|
||||
os.mkdir(app_dir)
|
||||
|
||||
talon_file_created = create_file(talon_file, talon_context)
|
||||
python_file_created = create_file(python_file, python_context)
|
||||
|
||||
if talon_file_created or python_file_created:
|
||||
actions.user.file_manager_open_directory(str(app_dir))
|
||||
|
||||
|
||||
def get_python_context(active_app: ui.App, app_name: str) -> str:
|
||||
return '''\
|
||||
from talon import Module, Context, actions
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
|
||||
mod.apps.{app_name} = r"""
|
||||
os: {os}
|
||||
and {app_context}
|
||||
"""
|
||||
|
||||
ctx.matches = r"""
|
||||
os: {os}
|
||||
app: {app_name}
|
||||
"""
|
||||
|
||||
# @mod.action_class
|
||||
# class Actions:
|
||||
'''.format(
|
||||
app_name=app_name,
|
||||
os=app.platform,
|
||||
app_context=get_app_context(active_app),
|
||||
)
|
||||
|
||||
|
||||
def get_talon_context(app_name: str) -> str:
|
||||
return f"""app: {app_name}
|
||||
-
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def get_platform_filename(app_name: str, platform_suffix: str = None) -> str:
|
||||
if platform_suffix:
|
||||
return f"{app_name}_{platform_suffix}"
|
||||
return app_name
|
||||
|
||||
|
||||
def get_app_context(active_app: ui.App) -> str:
|
||||
if app.platform == "mac":
|
||||
return f"app.bundle: {active_app.bundle}"
|
||||
if app.platform == "windows":
|
||||
executable = os.path.basename(active_app.exe)
|
||||
return f"app.exe: /^{re.escape(executable.lower())}$/i"
|
||||
return f"app.name: {active_app.name}"
|
||||
|
||||
|
||||
def get_app_name(text: str, max_len=20) -> str:
|
||||
pattern = re.compile(r"[A-Z][a-z]*|[a-z]+|\d")
|
||||
return "_".join(
|
||||
list(islice(pattern.findall(text.removesuffix(".exe")), max_len))
|
||||
).lower()
|
||||
|
||||
|
||||
def create_file(path: Path, content: str) -> bool:
|
||||
if path.is_file():
|
||||
actions.app.notify(f"Application context file '{path}' already exists")
|
||||
return False
|
||||
|
||||
with open(path, "w", encoding="utf-8") as file:
|
||||
file.write(content)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,170 @@
|
||||
import os
|
||||
import platform
|
||||
import pprint
|
||||
import re
|
||||
from itertools import islice
|
||||
from typing import Union
|
||||
|
||||
from talon import Module, actions, app, clip, registry, scope, speech_system, ui
|
||||
from talon.grammar import Phrase
|
||||
from talon.scripting.types import ListTypeFull
|
||||
|
||||
pp = pprint.PrettyPrinter()
|
||||
|
||||
|
||||
mod = Module()
|
||||
pattern = re.compile(r"[A-Z][a-z]*|[a-z]+|\d")
|
||||
|
||||
|
||||
def create_name(text, max_len=20):
|
||||
return "_".join(list(islice(pattern.findall(text), max_len))).lower()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def talon_add_context_clipboard_python():
|
||||
"""Adds os-specific context info to the clipboard for the focused app for .py files. Assumes you've a Module named mod declared."""
|
||||
friendly_name = actions.app.name()
|
||||
# print(actions.app.executable())
|
||||
executable = os.path.basename(actions.app.executable())
|
||||
app_name = create_name(friendly_name.removesuffix(".exe"))
|
||||
if app.platform == "mac":
|
||||
result = 'mod.apps.{} = """\nos: mac\nand app.bundle: {}\n"""'.format(
|
||||
app_name, actions.app.bundle()
|
||||
)
|
||||
elif app.platform == "windows":
|
||||
result = 'mod.apps.{} = r"""\nos: windows\nand app.name: {}\nos: windows\nand app.exe: /^{}$/i\n"""'.format(
|
||||
app_name, friendly_name, re.escape(executable.lower())
|
||||
)
|
||||
else:
|
||||
result = 'mod.apps.{} = """\nos: {}\nand app.name: {}\n"""'.format(
|
||||
app_name, app.platform, friendly_name
|
||||
)
|
||||
|
||||
clip.set_text(result)
|
||||
|
||||
def talon_add_context_clipboard():
|
||||
"""Adds os-specific context info to the clipboard for the focused app for .talon files"""
|
||||
friendly_name = actions.app.name()
|
||||
# print(actions.app.executable())
|
||||
executable = os.path.basename(actions.app.executable())
|
||||
if app.platform == "mac":
|
||||
result = f"os: mac\nand app.bundle: {actions.app.bundle()}\n"
|
||||
elif app.platform == "windows":
|
||||
result = "os: windows\nand app.name: {}\nos: windows\nand app.exe: /^{}$/i\n".format(
|
||||
friendly_name, re.escape(executable.lower())
|
||||
)
|
||||
else:
|
||||
result = f"os: {app.platform}\nand app.name: {friendly_name}\n"
|
||||
|
||||
clip.set_text(result)
|
||||
|
||||
def talon_sim_phrase(phrase: Union[str, Phrase]):
|
||||
"""Sims the phrase in the active app and dumps to the log"""
|
||||
print("**** Simulated Phrase **** ")
|
||||
print(speech_system._sim(str(phrase)))
|
||||
print("*************************")
|
||||
|
||||
def talon_action_find(action: str):
|
||||
"""Runs action.find for the provided action and dumps to the log"""
|
||||
print(f"**** action.find{action} **** ")
|
||||
print(actions.find(action))
|
||||
print("***********************")
|
||||
|
||||
def talon_debug_list(name: str):
|
||||
"""Dumps the contents of list to the console"""
|
||||
print(f"**** Dumping list {name} **** ")
|
||||
|
||||
print(str(registry.lists[name]))
|
||||
print("***********************")
|
||||
|
||||
def talon_debug_tags():
|
||||
"""Dumps the active tags to the console"""
|
||||
print("**** Dumping active tags *** ")
|
||||
print(str(registry.tags))
|
||||
print("***********************")
|
||||
|
||||
def talon_debug_modes():
|
||||
"""Dumps active modes to the console"""
|
||||
print("**** Active modes ****")
|
||||
print(scope.get("mode"))
|
||||
print("***********************")
|
||||
|
||||
def talon_debug_scope(name: str):
|
||||
"""Dumps the active scope information to the console"""
|
||||
print(f"**** Dumping {name} scope ****")
|
||||
print(scope.get(name))
|
||||
print("***********************")
|
||||
|
||||
def talon_copy_list(name: str):
|
||||
"""Dumps the contents of list to the console"""
|
||||
print(f"**** Copied list {name} **** ")
|
||||
clip.set_text(pp.pformat(registry.lists[name]))
|
||||
print("***********************")
|
||||
|
||||
def talon_debug_setting(name: str):
|
||||
"""Dumps the current setting to the console"""
|
||||
print(f"**** Dumping setting {name} **** ")
|
||||
print(registry.settings[name])
|
||||
print("***********************")
|
||||
|
||||
def talon_debug_all_settings():
|
||||
"""Dumps all settings to the console"""
|
||||
print("**** Dumping settings **** ")
|
||||
print(str(registry.settings))
|
||||
print("***********************")
|
||||
|
||||
def talon_get_active_context() -> str:
|
||||
"""Returns active context info"""
|
||||
name = actions.app.name()
|
||||
executable = actions.app.executable()
|
||||
bundle = actions.app.bundle()
|
||||
title = actions.win.title()
|
||||
hostname = scope.get("hostname")
|
||||
result = f"Name: {name}\nExecutable: {executable}\nBundle: {bundle}\nTitle: {title}\nhostname: {hostname}"
|
||||
return result
|
||||
|
||||
def talon_get_hostname() -> str:
|
||||
"""Returns the hostname"""
|
||||
hostname = scope.get("hostname")
|
||||
return hostname
|
||||
|
||||
def talon_get_active_application_info() -> str:
|
||||
"""Returns all active app info to the cliboard"""
|
||||
result = str(ui.active_app())
|
||||
result += "\nActive window: " + str(ui.active_window())
|
||||
result += "\nWindows: " + str(ui.active_app().windows())
|
||||
result += "\nName: " + actions.app.name()
|
||||
result += "\nExecutable: " + actions.app.executable()
|
||||
result += "\nBundle: " + actions.app.bundle()
|
||||
result += "\nTitle: " + actions.win.title()
|
||||
return result
|
||||
|
||||
def talon_get_active_window_class_name() -> str:
|
||||
"""Returns the class name of the active window"""
|
||||
return ui.active_window().cls
|
||||
|
||||
def talon_version_info() -> str:
|
||||
"""Returns talon & operation system verison information"""
|
||||
result = (
|
||||
f"Version: {app.version}, Branch: {app.branch}, OS: {platform.platform()}"
|
||||
)
|
||||
return result
|
||||
|
||||
def talon_pretty_print(obj: object):
|
||||
"""Uses pretty print to dump an object"""
|
||||
pp.pprint(obj)
|
||||
|
||||
def talon_pretty_format(obj: object):
|
||||
"""Pretty formats an object"""
|
||||
return pp.pformat(obj)
|
||||
|
||||
def talon_debug_app_windows(app: str):
|
||||
"""Pretty prints the application windows"""
|
||||
apps = ui.apps(name=app, background=False)
|
||||
for app in apps:
|
||||
pp.pprint(app.windows())
|
||||
|
||||
def talon_get_active_registry_list(name: str) -> ListTypeFull:
|
||||
"""Returns the active list from the Talon registry"""
|
||||
return registry.lists[name][-1]
|
||||
@@ -0,0 +1,64 @@
|
||||
talon check updates: menu.check_for_updates()
|
||||
# the debug window is only available in the talon beta
|
||||
talon open debug: menu.open_debug_window()
|
||||
talon open log: menu.open_log()
|
||||
talon open rebel: menu.open_repl()
|
||||
talon home: menu.open_talon_home()
|
||||
talon copy context pie: user.talon_add_context_clipboard_python()
|
||||
talon copy context: user.talon_add_context_clipboard()
|
||||
talon copy name:
|
||||
name = app.name()
|
||||
clip.set_text(name)
|
||||
talon copy executable:
|
||||
executable = app.executable()
|
||||
clip.set_text(executable)
|
||||
talon copy bundle:
|
||||
bundle = app.bundle()
|
||||
clip.set_text(bundle)
|
||||
talon copy title:
|
||||
title = win.title()
|
||||
clip.set_text(title)
|
||||
talon copy class:
|
||||
class_name = user.talon_get_active_window_class_name()
|
||||
clip.set_text(class_name)
|
||||
talon dump version:
|
||||
result = user.talon_version_info()
|
||||
print(result)
|
||||
talon insert version:
|
||||
result = user.talon_version_info()
|
||||
user.paste(result)
|
||||
talon dump context:
|
||||
result = user.talon_get_active_context()
|
||||
print(result)
|
||||
^talon test last$:
|
||||
phrase = user.history_get(1)
|
||||
user.talon_sim_phrase(phrase)
|
||||
^talon test numb <number_small>$:
|
||||
phrase = user.history_get(number_small)
|
||||
user.talon_sim_phrase(phrase)
|
||||
^talon test <phrase>$: user.talon_sim_phrase(phrase)
|
||||
^talon debug action {user.talon_actions}$:
|
||||
user.talon_action_find("{user.talon_actions}")
|
||||
^talon debug list {user.talon_lists}$: user.talon_debug_list(talon_lists)
|
||||
^talon copy list {user.talon_lists}$: user.talon_copy_list(talon_lists)
|
||||
^talon debug tags$: user.talon_debug_tags()
|
||||
^talon debug modes$: user.talon_debug_modes()
|
||||
^talon debug scope {user.talon_scopes}$: user.talon_debug_scope(talon_scopes)
|
||||
^talon debug setting {user.talon_settings}$: user.talon_debug_setting(talon_settings)
|
||||
^talon debug all settings$: user.talon_debug_all_settings()
|
||||
^talon debug active app$:
|
||||
result = user.talon_get_active_application_info()
|
||||
print("**** Dumping active application **** ")
|
||||
print(result)
|
||||
print("***********************")
|
||||
^talon copy active app$:
|
||||
result = user.talon_get_active_application_info()
|
||||
clip.set_text(result)
|
||||
|
||||
^talon create app context$: user.talon_create_app_context()
|
||||
^talon create windows app context$: user.talon_create_app_context("win")
|
||||
^talon create linux app context$: user.talon_create_app_context("linux")
|
||||
^talon create mac app context$: user.talon_create_app_context("mac")
|
||||
|
||||
talon (bug report | report bug):
|
||||
user.open_url("https://github.com/talonhub/community/issues")
|
||||
@@ -0,0 +1,4 @@
|
||||
list: user.before_or_after
|
||||
-
|
||||
before: BEFORE
|
||||
after: AFTER
|
||||
@@ -0,0 +1,9 @@
|
||||
list: user.navigation_action
|
||||
-
|
||||
|
||||
move: GO
|
||||
extend: EXTEND
|
||||
select: SELECT
|
||||
clear: DELETE
|
||||
cut: CUT
|
||||
copy: COPY
|
||||
@@ -0,0 +1,342 @@
|
||||
import itertools
|
||||
import re
|
||||
|
||||
from talon import Context, Module, actions, settings
|
||||
|
||||
ctx = Context()
|
||||
mod = Module()
|
||||
|
||||
|
||||
mod.setting(
|
||||
"text_navigation_max_line_search",
|
||||
type=int,
|
||||
default=10,
|
||||
desc="The maximum number of rows that will be included in the search for the keywords above and below in <user direction>",
|
||||
)
|
||||
|
||||
mod.list(
|
||||
"navigation_action",
|
||||
desc="Actions to perform, for instance move, select, cut, etc",
|
||||
)
|
||||
mod.list(
|
||||
"before_or_after",
|
||||
desc="Words to indicate if the cursor should be moved before or after a given reference point",
|
||||
)
|
||||
mod.list(
|
||||
"navigation_target_name",
|
||||
desc="Names for regular expressions for common things to navigate to, for instance a word with or without underscores",
|
||||
)
|
||||
|
||||
navigation_target_names = {
|
||||
"word": r"\w+",
|
||||
"small": r"[A-Z]?[a-z0-9]+",
|
||||
"big": r"[\S]+",
|
||||
"parens": r"\((.*?)\)",
|
||||
"squares": r"\[(.*?)\]",
|
||||
"braces": r"\{(.*?)\}",
|
||||
"quotes": r"\"(.*?)\"",
|
||||
"angles": r"\<(.*?)\>",
|
||||
# "single quotes": r'\'(.*?)\'',
|
||||
"all": r"(.+)",
|
||||
"method": r"\w+\((.*?)\)",
|
||||
"constant": r"[A-Z_][A-Z_]+",
|
||||
}
|
||||
ctx.lists["self.navigation_target_name"] = navigation_target_names
|
||||
|
||||
|
||||
@mod.capture(
|
||||
rule="<user.any_alphanumeric_key> | {user.navigation_target_name} | phrase <user.text>"
|
||||
)
|
||||
def navigation_target(m) -> re.Pattern:
|
||||
"""A target to navigate to. Returns a regular expression."""
|
||||
if hasattr(m, "any_alphanumeric_key"):
|
||||
return re.compile(re.escape(m.any_alphanumeric_key), re.IGNORECASE)
|
||||
if hasattr(m, "navigation_target_name"):
|
||||
return re.compile(m.navigation_target_name)
|
||||
return re.compile(re.escape(m.text), re.IGNORECASE)
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def navigation(
|
||||
navigation_action: str, # GO, EXTEND, SELECT, DELETE, CUT, COPY
|
||||
direction: str, # up, down, left, right
|
||||
navigation_target_name: str,
|
||||
before_or_after: str, # BEFORE, AFTER, DEFAULT
|
||||
regex: re.Pattern,
|
||||
occurrence_number: int,
|
||||
):
|
||||
"""Navigate in `direction` to the occurrence_number-th time that `regex` occurs, then execute `navigation_action` at the given `before_or_after` position."""
|
||||
direction = direction.upper()
|
||||
navigation_target_name = re.compile(
|
||||
navigation_target_names["word"]
|
||||
if (navigation_target_name == "DEFAULT")
|
||||
else navigation_target_name
|
||||
)
|
||||
function = navigate_left if direction in ("UP", "LEFT") else navigate_right
|
||||
function(
|
||||
navigation_action,
|
||||
navigation_target_name,
|
||||
before_or_after,
|
||||
regex,
|
||||
occurrence_number,
|
||||
direction,
|
||||
)
|
||||
|
||||
def navigation_by_name(
|
||||
navigation_action: str, # GO, EXTEND, SELECT, DELETE, CUT, COPY
|
||||
direction: str, # up, down, left, right
|
||||
before_or_after: str, # BEFORE, AFTER, DEFAULT
|
||||
navigation_target_name: str, # word, big, small
|
||||
occurrence_number: int,
|
||||
):
|
||||
"""Like user.navigation, but to a named target."""
|
||||
r = re.compile(navigation_target_names[navigation_target_name])
|
||||
actions.user.navigation(
|
||||
navigation_action,
|
||||
direction,
|
||||
"DEFAULT",
|
||||
before_or_after,
|
||||
r,
|
||||
occurrence_number,
|
||||
)
|
||||
|
||||
|
||||
def get_text_left():
|
||||
actions.edit.extend_line_start()
|
||||
text = actions.edit.selected_text()
|
||||
actions.edit.right()
|
||||
return text
|
||||
|
||||
|
||||
def get_text_right():
|
||||
actions.edit.extend_line_end()
|
||||
text = actions.edit.selected_text()
|
||||
actions.edit.left()
|
||||
return text
|
||||
|
||||
|
||||
def get_text_up():
|
||||
actions.edit.up()
|
||||
actions.edit.line_end()
|
||||
for j in range(0, settings.get("user.text_navigation_max_line_search")):
|
||||
actions.edit.extend_up()
|
||||
actions.edit.extend_line_start()
|
||||
text = actions.edit.selected_text()
|
||||
actions.edit.right()
|
||||
return text
|
||||
|
||||
|
||||
def get_text_down():
|
||||
actions.edit.down()
|
||||
actions.edit.line_start()
|
||||
for j in range(0, settings.get("user.text_navigation_max_line_search")):
|
||||
actions.edit.extend_down()
|
||||
actions.edit.extend_line_end()
|
||||
text = actions.edit.selected_text()
|
||||
actions.edit.left()
|
||||
return text
|
||||
|
||||
|
||||
def get_current_selection_size():
|
||||
return len(actions.edit.selected_text())
|
||||
|
||||
|
||||
def go_right(i):
|
||||
for j in range(0, i):
|
||||
actions.edit.right()
|
||||
|
||||
|
||||
def go_left(i):
|
||||
for j in range(0, i):
|
||||
actions.edit.left()
|
||||
|
||||
|
||||
def extend_left(i):
|
||||
for j in range(0, i):
|
||||
actions.edit.extend_left()
|
||||
|
||||
|
||||
def extend_right(i):
|
||||
for j in range(0, i):
|
||||
actions.edit.extend_right()
|
||||
|
||||
|
||||
def select(direction, start, end, length):
|
||||
if direction == "RIGHT" or direction == "DOWN":
|
||||
go_right(start)
|
||||
extend_right(end - start)
|
||||
else:
|
||||
go_left(length - end)
|
||||
extend_left(end - start)
|
||||
|
||||
|
||||
def navigate_left(
|
||||
navigation_action,
|
||||
navigation_target_name,
|
||||
before_or_after,
|
||||
regex,
|
||||
occurrence_number,
|
||||
direction,
|
||||
):
|
||||
current_selection_length = get_current_selection_size()
|
||||
if current_selection_length > 0:
|
||||
actions.edit.right()
|
||||
text = get_text_left() if direction == "LEFT" else get_text_up()
|
||||
# only search in the text that was not selected
|
||||
subtext = (
|
||||
text if current_selection_length <= 0 else text[:-current_selection_length]
|
||||
)
|
||||
match = match_backwards(regex, occurrence_number, subtext)
|
||||
if match is None:
|
||||
# put back the old selection, if the search failed
|
||||
extend_left(current_selection_length)
|
||||
return
|
||||
start = match.start()
|
||||
end = match.end()
|
||||
handle_navigation_action(
|
||||
navigation_action,
|
||||
navigation_target_name,
|
||||
before_or_after,
|
||||
direction,
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
|
||||
def navigate_right(
|
||||
navigation_action,
|
||||
navigation_target_name,
|
||||
before_or_after,
|
||||
regex,
|
||||
occurrence_number,
|
||||
direction,
|
||||
):
|
||||
current_selection_length = get_current_selection_size()
|
||||
if current_selection_length > 0:
|
||||
actions.edit.left()
|
||||
text = get_text_right() if direction == "RIGHT" else get_text_down()
|
||||
# only search in the text that was not selected
|
||||
sub_text = text[current_selection_length:]
|
||||
# pick the next interrater, Skip n number of occurrences, get an iterator given the Regex
|
||||
match = match_forward(regex, occurrence_number, sub_text)
|
||||
if match is None:
|
||||
# put back the old selection, if the search failed
|
||||
extend_right(current_selection_length)
|
||||
return
|
||||
start = current_selection_length + match.start()
|
||||
end = current_selection_length + match.end()
|
||||
handle_navigation_action(
|
||||
navigation_action,
|
||||
navigation_target_name,
|
||||
before_or_after,
|
||||
direction,
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
|
||||
def handle_navigation_action(
|
||||
navigation_action,
|
||||
navigation_target_name,
|
||||
before_or_after,
|
||||
direction,
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
):
|
||||
length = len(text)
|
||||
if navigation_action == "GO":
|
||||
handle_move(direction, before_or_after, start, end, length)
|
||||
elif navigation_action == "SELECT":
|
||||
handle_select(
|
||||
navigation_target_name, before_or_after, direction, text, start, end, length
|
||||
)
|
||||
elif navigation_action == "DELETE":
|
||||
handle_select(
|
||||
navigation_target_name, before_or_after, direction, text, start, end, length
|
||||
)
|
||||
actions.edit.delete()
|
||||
elif navigation_action == "CUT":
|
||||
handle_select(
|
||||
navigation_target_name, before_or_after, direction, text, start, end, length
|
||||
)
|
||||
actions.edit.cut()
|
||||
elif navigation_action == "COPY":
|
||||
handle_select(
|
||||
navigation_target_name, before_or_after, direction, text, start, end, length
|
||||
)
|
||||
actions.edit.copy()
|
||||
elif navigation_action == "EXTEND":
|
||||
handle_extend(before_or_after, direction, start, end, length)
|
||||
|
||||
|
||||
def handle_select(
|
||||
navigation_target_name, before_or_after, direction, text, start, end, length
|
||||
):
|
||||
if before_or_after == "BEFORE":
|
||||
select_left = length - start
|
||||
text_left = text[:-select_left]
|
||||
match2 = match_backwards(navigation_target_name, 1, text_left)
|
||||
if match2 is None:
|
||||
end = start
|
||||
start = 0
|
||||
else:
|
||||
start = match2.start()
|
||||
end = match2.end()
|
||||
elif before_or_after == "AFTER":
|
||||
text_right = text[end:]
|
||||
match2 = match_forward(navigation_target_name, 1, text_right)
|
||||
if match2 is None:
|
||||
start = end
|
||||
end = length
|
||||
else:
|
||||
start = end + match2.start()
|
||||
end = end + match2.end()
|
||||
select(direction, start, end, length)
|
||||
|
||||
|
||||
def handle_move(direction, before_or_after, start, end, length):
|
||||
if direction == "RIGHT" or direction == "DOWN":
|
||||
if before_or_after == "BEFORE":
|
||||
go_right(start)
|
||||
else:
|
||||
go_right(end)
|
||||
else:
|
||||
if before_or_after == "AFTER":
|
||||
go_left(length - end)
|
||||
else:
|
||||
go_left(length - start)
|
||||
|
||||
|
||||
def handle_extend(before_or_after, direction, start, end, length):
|
||||
if direction == "RIGHT" or direction == "DOWN":
|
||||
if before_or_after == "BEFORE":
|
||||
extend_right(start)
|
||||
else:
|
||||
extend_right(end)
|
||||
else:
|
||||
if before_or_after == "AFTER":
|
||||
extend_left(length - end)
|
||||
else:
|
||||
extend_left(length - start)
|
||||
|
||||
|
||||
def match_backwards(regex, occurrence_number, subtext):
|
||||
try:
|
||||
match = list(regex.finditer(subtext))[-occurrence_number]
|
||||
return match
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
|
||||
def match_forward(regex, occurrence_number, sub_text):
|
||||
try:
|
||||
match = next(
|
||||
itertools.islice(regex.finditer(sub_text), occurrence_number - 1, None)
|
||||
)
|
||||
return match
|
||||
except StopIteration:
|
||||
return None
|
||||
@@ -0,0 +1,83 @@
|
||||
## (2021-03-09) This syntax is experimental and may change. See below for an explanation.
|
||||
## If you are having issues with this module not working in vscode try adding the vscode setting "editor.emptySelectionClipboard": false
|
||||
navigate [{user.arrow_key}] [{user.navigation_action}] [{user.navigation_target_name}] [{user.before_or_after}] [<user.ordinals>] <user.navigation_target>:
|
||||
## If you use this command a lot, you may wish to have a shorter syntax that omits the navigate keyword. Note that you then at least have to say either a navigation_action or before_or_after:
|
||||
#({user.navigation_action} [{user.arrow_key}] [{user.navigation_target_name}] [{user.before_or_after}] | [{user.arrow_key}] {user.before_or_after}) [<user.ordinals>] <user.navigation_target>:
|
||||
user.navigation(navigation_action or "GO", arrow_key or "RIGHT", navigation_target_name or "DEFAULT", before_or_after or "DEFAULT", navigation_target, ordinals or 1)
|
||||
|
||||
# ===== Examples of use =====
|
||||
#
|
||||
# navigate comma: moves after the next "," on the line.
|
||||
# navigate before five: moves before the next "5" on the line.
|
||||
# navigate left underscore: moves before the previous "_" on the line.
|
||||
# navigate left after second plex: moves after the second-previous "x" on the line.
|
||||
#
|
||||
# Besides characters, we can find phrases or move in predetermined units:
|
||||
#
|
||||
# navigate phrase hello world: moves after the next "hello world" on the line.
|
||||
# navigate left third word: moves left over three words.
|
||||
# navigate before second big: moves before the second-next 'big' word (a chunk of anything except white space).
|
||||
# navigate left second small: moves left over two 'small' words (chunks of a camelCase name).
|
||||
#
|
||||
# We can search several lines (default 10) above or below the cursor:
|
||||
#
|
||||
# navigate up phrase john: moves before the previous "john" (case-insensitive) on the preceding lines.
|
||||
# navigate down third period: moves after the third period on the following lines.
|
||||
#
|
||||
# Besides movement, we can cut, copy, select, clear (delete), or extend the current selection:
|
||||
#
|
||||
# navigate cut after comma: cut the word following the next comma on the line.
|
||||
# navigate left copy third word: copy the third word to the left.
|
||||
# navigate extend third big: extend the selection three big words right.
|
||||
# navigate down clear phrase I think: delete the next occurrence of "I think" on the following lines.
|
||||
# navigate up select colon: select the closest colon on the preceeding lines.
|
||||
#
|
||||
# We can specify what gets selected before or after the given input:
|
||||
#
|
||||
# navigate select parens after equals: Select the first "(" and everything until the first ")" after the "="
|
||||
# navigate left copy all before equals: Copy everything from the start of the line until the first "=" you encounter while moving left
|
||||
# navigate clear constant before semicolon: Delete the last word consisting of only uppercase characters or underscores before a ";"
|
||||
#
|
||||
# ===== Explanation of the grammar =====
|
||||
#
|
||||
# [{user.arrow_key}]: left, right, up, down (default: right)
|
||||
# Which direction to navigate in.
|
||||
# left/right work on the current line.
|
||||
# up/down work on the closest lines (default: 10) above or below.
|
||||
#
|
||||
# [{user.navigation_action}]: move, extend, select, clear, cut, copy (default: move)
|
||||
# What action to perform.
|
||||
#
|
||||
# [{user.navigation_target_name}]: word, small, big, parens, squares, braces, quotes, angles, all, method, constant (default: word)
|
||||
# The predetermined unit to select if before_or_after was specified.
|
||||
# Defaults to "word"
|
||||
#
|
||||
# [{user.before_or_after}]: before, after (default: special behavior)
|
||||
# For move/extend: where to leave the cursor, before or after the target.
|
||||
# Defaults to "after" for right/down and "before" for left/up.
|
||||
#
|
||||
# For select/copy/cut: if absent, select/copy/cut the target iself. If
|
||||
# present, the navigation_target_name before/after the target.
|
||||
#
|
||||
# [<user.ordinals>]: an english ordinal, like "second" (default: first)
|
||||
# Which occurrence of the target to navigate to.
|
||||
#
|
||||
# <user.navigation_target>: one of the following:
|
||||
# - a character name, like "comma" or "five".
|
||||
# - "word" or "big" or "small"
|
||||
# - "phrase <some text to search for>"
|
||||
# Specifies the target to search for/navigate to.
|
||||
|
||||
# The functionality for all these commands is covered in the lines above, but these commands are kept here for convenience. Originally from word_selection.talon.
|
||||
word neck [<number_small>]:
|
||||
user.navigation_by_name("SELECT", "RIGHT", "DEFAULT", "word", number_small or 1)
|
||||
word pre [<number_small>]:
|
||||
user.navigation_by_name("SELECT", "LEFT", "DEFAULT", "word", number_small or 1)
|
||||
small word neck [<number_small>]:
|
||||
user.navigation_by_name("SELECT", "RIGHT", "DEFAULT", "small", number_small or 1)
|
||||
small word pre [<number_small>]:
|
||||
user.navigation_by_name("SELECT", "LEFT", "DEFAULT", "small", number_small or 1)
|
||||
big word neck [<number_small>]:
|
||||
user.navigation_by_name("SELECT", "RIGHT", "DEFAULT", "big", number_small or 1)
|
||||
big word pre [<number_small>]:
|
||||
user.navigation_by_name("SELECT", "LEFT", "DEFAULT", "big", number_small or 1)
|
||||
@@ -0,0 +1,4 @@
|
||||
# This makes it easier to chain commands by letting you hint about command boundaries.
|
||||
# For example, with Cursorless, the phrase "post line air" is ambiguous as to whether you meant a single command ("post line air", i.e. "move the cursor to the end of the line containing the 'a' hat"), or two separate commands ("post line" to move the cursor to the end of the current line, followed by "air" to insert the letter "a").
|
||||
# If you know you want the latter, this allows you to say "post line then air" to force that interpretation.
|
||||
then: skip()
|
||||