Skip to content

Commit aa4686b

Browse files
fpugartuszik
authored andcommitted
feat: chords (separate keypress actions) are allowed
Shorcuts like `Super+T W S` are allowed in highlighted with animation transitions in the keyboard.
1 parent e91c2f9 commit aa4686b

File tree

12 files changed

+336
-23
lines changed

12 files changed

+336
-23
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ requires-python = ">=3.9"
77
dependencies = ["jinja2>=3.1.6", "python-dotenv>=1.1.1", "ruamel-yaml>=0.18.14"]
88

99
[dependency-groups]
10-
dev = ["bandit>=1.8.6", "ruff>=0.14.0", "ty>=0.0.1a22", "vulture>=2.14"]
10+
dev = ["bandit>=1.8.6", "ruff>=0.14.0", "ty>=0.0.1a22", "vulture>=2.14", "pytest>=8.4.2"]
11+
1112

1213

1314
[tool.ruff]

pytest.ini

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[pytest]
2+
testpaths = tests
3+
python_files = test_*.py
4+
python_classes = Test*
5+
python_functions = test_*
6+
addopts =
7+
-v
8+
--tb=short
9+
--strict-markers
10+
--disable-warnings
11+

src/generate_cheatsheet.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
from ruamel.yaml import YAML
2-
import sys
31
import os
4-
from validate_yaml import validate_yaml, lint_yaml
2+
import re
3+
import sys
4+
from pathlib import Path
5+
56
from dotenv import load_dotenv
6-
from template_renderer import render_template
7+
from ruamel.yaml import YAML
8+
79
from logger import get_logger
8-
from pathlib import Path
10+
from template_renderer import render_template
11+
from validate_yaml import lint_yaml, validate_yaml
912

1013
yaml_safe = YAML(typ="safe")
1114
yaml_rw = YAML()
@@ -56,17 +59,32 @@ def replace_shortcut_names(shortcut, system_mappings):
5659
try:
5760
processed_parts = []
5861
i = 0
62+
shortcut = re.sub(r"(\+|\>)\s*(\+|\>)", "\g<1>\g<2>", shortcut)
63+
5964
while i < len(shortcut):
6065
if shortcut[i] == "+":
6166
if i + 1 < len(shortcut) and shortcut[i + 1] == "+":
62-
processed_parts.append("+")
67+
processed_parts.append("<sep>+")
68+
i += 2
69+
elif i + 1 < len(shortcut) and shortcut[i + 1] == ">":
70+
processed_parts.append("<sep>>")
6371
i += 2
6472
else:
6573
processed_parts.append("<sep>")
6674
i += 1
75+
elif shortcut[i] == ">":
76+
if i + 1 < len(shortcut) and shortcut[i + 1] == ">":
77+
processed_parts.append("<seq>>")
78+
i += 2
79+
elif i + 1 < len(shortcut) and shortcut[i + 1] == "+":
80+
processed_parts.append("<seq>+")
81+
i += 2
82+
else:
83+
processed_parts.append("<seq>")
84+
i += 1
6785
else:
6886
current_part = ""
69-
while i < len(shortcut) and shortcut[i] != "+":
87+
while i < len(shortcut) and shortcut[i] not in ("+", ">"):
7088
current_part += shortcut[i]
7189
i += 1
7290
if current_part.strip():

src/layouts/keyboard_layouts.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ ES:
269269
"´",
270270
"Enter",
271271
]
272-
- ["Shift", "Ç", "Z", "X", "C", "V", "B", "N", "M", ",", ".", "-", "Shift"]
272+
- ["Shift", ">", "Z", "X", "C", "V", "B", "N", "M", ",", ".", "-", "Shift"]
273273
- ["Ctrl", "Alt", "Cmd", "Space", "Cmd", "Alt", "Ctrl"]
274274

275275
DVORAK:

src/templates/cheatsheets/assets/cheatsheets.css

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ body.dark-mode .shortcut-key .key-part {
238238
margin-left: 0.3em;
239239
}
240240

241+
.shortcut-key .sequence-separator {
242+
white-space-collapse: preserve;
243+
}
244+
241245
body.dark-mode .shortcut-key .separator {
242246
color: var(--mocha-subtext0);
243247
}
@@ -338,6 +342,67 @@ body.dark-mode .key.active {
338342
color: var(--mocha-base);
339343
box-shadow: 0 0 8px var(--mocha-green);
340344
}
345+
.key.active-step-1 {
346+
background: var(--latte-green);
347+
color: var(--latte-base);
348+
box-shadow: 0 0 8px var(--latte-green);
349+
}
350+
body.dark-mode .key.active-step-1 {
351+
background: var(--mocha-green);
352+
color: var(--mocha-base);
353+
box-shadow: 0 0 8px var(--mocha-green);
354+
}
355+
.key.active-step-2 {
356+
background: var(--latte-sapphire);
357+
color: var(--latte-base);
358+
box-shadow: 0 0 8px var(--latte-sapphire);
359+
}
360+
body.dark-mode .key.active-step-2 {
361+
background: var(--mocha-sapphire);
362+
color: var(--mocha-base);
363+
box-shadow: 0 0 8px var(--mocha-sapphire);
364+
}
365+
.key.active-step-3 {
366+
background: var(--latte-peach);
367+
color: var(--latte-base);
368+
box-shadow: 0 0 8px var(--latte-peach);
369+
}
370+
body.dark-mode .key.active-step-3 {
371+
background: var(--mocha-peach);
372+
color: var(--mocha-base);
373+
box-shadow: 0 0 8px var(--mocha-peach);
374+
}
375+
.key.active-step-4 {
376+
background: var(--latte-mauve);
377+
color: var(--latte-base);
378+
box-shadow: 0 0 8px var(--latte-mauve);
379+
}
380+
body.dark-mode .key.active-step-4 {
381+
background: var(--mocha-mauve);
382+
color: var(--mocha-base);
383+
box-shadow: 0 0 8px var(--mocha-mauve);
384+
}
385+
.key.active-step-5 {
386+
background: var(--latte-lavender);
387+
color: var(--latte-base);
388+
box-shadow: 0 0 8px var(--latte-lavender);
389+
}
390+
body.dark-mode .key.active-step-5 {
391+
background: var(--mocha-lavender);
392+
color: var(--mocha-base);
393+
box-shadow: 0 0 8px var(--mocha-lavender);
394+
}
395+
/* Default style for steps greater than 5 */
396+
.key[class*="active-step-"]:not(.active-step-1):not(.active-step-2):not(.active-step-3):not(.active-step-4):not(.active-step-5) {
397+
background: var(--latte-yellow);
398+
color: var(--latte-base);
399+
box-shadow: 0 0 8px var(--latte-yellow);
400+
}
401+
body.dark-mode .key[class*="active-step-"]:not(.active-step-1):not(.active-step-2):not(.active-step-3):not(.active-step-4):not(.active-step-5) {
402+
background: var(--mocha-yellow);
403+
color: var(--mocha-base);
404+
box-shadow: 0 0 8px var(--mocha-yellow);
405+
}
341406
.key__wide {
342407
width: 75px;
343408
}

src/templates/cheatsheets/components/body.html

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,13 @@ <h2>{{ section }}</h2>
145145
{% if allow_text %}
146146
{{ shortcut }}
147147
{% else %}
148-
{% for key in shortcut.split('<sep>') %}
149-
<span class="key-part">{{ key | safe }}</span>
150-
{% if not loop.last %}<span class="separator">+</span>{% endif %}
148+
{% set key_groups = shortcut.split('<seq>') %}
149+
{% for group in key_groups %}
150+
{% if loop.index > 1 %}<span class="sequence-separator"> </span>{% endif %}
151+
{% for key in group.split('<sep>') %}
152+
<span class="key-part">{{ key | safe }}</span>
153+
{% if not loop.last %}<span class="separator">+</span>{% endif %}
154+
{% endfor %}
151155
{% endfor %}
152156
{% endif %}
153157
</div>

src/templates/cheatsheets/scripts/main.js

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,21 @@
171171
updateDarkModeToggle();
172172
adjustLayout();
173173

174-
function highlightKeys(shortcut) {
175-
// Clear any previously highlighted keys
176-
document.querySelectorAll(".key").forEach((key) => key.classList.remove("active"));
174+
// Helper function to clear all active classes from keys
175+
function clearActiveKeyClasses() {
176+
const allClasses = ["active"];
177+
// Generate classes dynamically for steps (support up to 20 steps)
178+
for (let i = 1; i <= 20; i++) {
179+
allClasses.push(`active-step-${i}`);
180+
}
181+
document.querySelectorAll(".key").forEach((key) => {
182+
allClasses.forEach(className => key.classList.remove(className));
183+
});
184+
}
177185

178-
// Get the shortcut key parts
179-
const shortcutParts = shortcut.split('+').map(part => part.trim());
180-
const system = document.getElementById("keyboard").getAttribute("data-system");
181186

182-
shortcutParts.forEach((keyToFind) => {
187+
function highlightChordsPart(chordsPart, stepClass) {
188+
chordsPart.forEach((keyToFind) => {
183189
keyToFind = keyToFind.toLowerCase();
184190
const keyElements = document.querySelectorAll(".key");
185191

@@ -192,18 +198,60 @@
192198
keyLabel === keyToFind ||
193199
(keyToFind === "cmd" && (dataKey === "cmd" || dataKey === "win" || dataKey === "super"))
194200
) {
195-
element.classList.add("active");
201+
element.classList.add(stepClass);
196202
}
197203
});
198204
});
199205
}
200206

207+
// Track the current animation timeout so we can cancel/reset it
208+
let sequentialShortcutTimeout = null;
209+
210+
function animateChords(chords) {
211+
// Cancel any ongoing animation
212+
if (sequentialShortcutTimeout) {
213+
clearTimeout(sequentialShortcutTimeout);
214+
sequentialShortcutTimeout = null;
215+
}
216+
217+
clearActiveKeyClasses();
218+
219+
const animationDelay = 500;
220+
let currentStep = 0;
221+
222+
const animateStep = () => {
223+
if (currentStep < chords.length) {
224+
const stepClass = `active-step-${currentStep + 1}`;
225+
highlightChordsPart(chords[currentStep], stepClass);
226+
currentStep++;
227+
sequentialShortcutTimeout = setTimeout(animateStep, animationDelay);
228+
} else {
229+
sequentialShortcutTimeout = null;
230+
}
231+
};
232+
233+
animateStep();
234+
}
235+
201236
document.querySelectorAll(".shortcut").forEach((shortcut) => {
202237
{% if not allow_text %}
203238
shortcut.addEventListener("click", function() {
204-
// Get the text content and replace <sep> with +
205-
const shortcutKey = this.querySelector(".shortcut-key").textContent.replace(/\s*\+\s*/g, '+');
206-
highlightKeys(shortcutKey);
239+
240+
const isKeyPart = (x) => {return x.classList.contains("key-part")}
241+
const isSequenceSeparator = (x) => {return x.classList.contains("sequence-separator")}
242+
243+
const chords=[[]];
244+
let chords_idx=0;
245+
this.querySelectorAll(".shortcut-key span").forEach(x => {
246+
if (isKeyPart(x)) {
247+
chords[chords_idx].push(x.textContent.trim())
248+
};
249+
if (isSequenceSeparator(x)) {
250+
chords.push([]);
251+
chords_idx++;
252+
}
253+
})
254+
animateChords(chords);
207255
});
208256
{% endif %}
209257
});

tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Tests for KoalaKeys
2+

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Pytest configuration and fixtures."""
2+
3+
import sys
4+
from pathlib import Path
5+
6+
# Add src directory to Python path
7+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Tests for the replace_shortcut_names function."""
2+
3+
import pytest
4+
5+
from generate_cheatsheet import replace_shortcut_names
6+
7+
8+
def test_three_keys():
9+
"""Test Ctrl+Shift+A -> Ctrl<sep>Shift<sep>A"""
10+
result = replace_shortcut_names("Ctrl+Shift+A", {})
11+
expected = "Ctrl<sep>Shift<sep>A"
12+
assert result == expected
13+
14+
15+
def test_plus_key():
16+
"""Test Ctrl++ -> Ctrl<sep>+"""
17+
result = replace_shortcut_names("Ctrl++", {})
18+
expected = "Ctrl<sep>+"
19+
assert result == expected
20+
21+
22+
def test_angle_bracket():
23+
"""Test CTRL+> -> CTRL<sep>>"""
24+
result = replace_shortcut_names("CTRL+>", {})
25+
expected = "CTRL<sep>>"
26+
assert result == expected
27+
28+
29+
def test_simple_chord():
30+
"""Test Super+T>W>S -> Super<sep>T<seq>W<seq>S"""
31+
result = replace_shortcut_names("Super+T>W>S", {})
32+
expected = "Super<sep>T<seq>W<seq>S"
33+
assert result == expected
34+
35+
36+
def test_composed_chord():
37+
"""Test CTRL+C>CTRL+K -> CTRL<sep>C<seq>CTRL<sep>K"""
38+
result = replace_shortcut_names("CTRL+C>CTRL+K", {})
39+
expected = "CTRL<sep>C<seq>CTRL<sep>K"
40+
assert result == expected
41+
42+
43+
def test_angle_bracket_in_chord():
44+
"""Test CTRL>> -> CTRL<seq>>"""
45+
result = replace_shortcut_names("CTRL>>", {})
46+
expected = "CTRL<seq>>"
47+
assert result == expected
48+
49+
50+
def test_plus_key_in_chord():
51+
"""Test CTRL>+ -> CTRL<seq>+"""
52+
result = replace_shortcut_names("CTRL>+", {})
53+
expected = "CTRL<seq>+"
54+
assert result == expected
55+
56+
57+
def test_spaces():
58+
assert replace_shortcut_names("Ctrl + C", {}) == "Ctrl<sep>C"
59+
assert replace_shortcut_names("Ctrl + Shift + A", {}) == "Ctrl<sep>Shift<sep>A"
60+
assert replace_shortcut_names("Ctrl + +", {}) == "Ctrl<sep>+"
61+
assert replace_shortcut_names("CTRL + >", {}) == "CTRL<sep>>"
62+
assert replace_shortcut_names("Super + T > W > S", {}) == "Super<sep>T<seq>W<seq>S"
63+
assert replace_shortcut_names("CTRL + C > CTRL + K", {}) == "CTRL<sep>C<seq>CTRL<sep>K"
64+
assert replace_shortcut_names("CTRL > >", {}) == "CTRL<seq>>"
65+
assert replace_shortcut_names("CTRL > +", {}) == "CTRL<seq>+"
66+
67+
68+
if __name__ == "__main__":
69+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)