init commit

This commit is contained in:
unknown
2025-08-19 08:06:37 -04:00
commit 2957b5515a
743 changed files with 45495 additions and 0 deletions
@@ -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:
![Video of talon draft window in action](doc/talon-draft-demo.gif)
# 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
Binary file not shown.

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