Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: pytest
on:
pull_request:
workflow_dispatch:
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-python
- run: |
uv run pytest
8 changes: 6 additions & 2 deletions docs/schema/cheatsheet.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"shortcuts": {
"type": "object",
"minProperties": 1,
"description": "Categories mapped to shortcut definitions.",
"description": "Categories mapped to shortcut definitions. Shortcuts use '+' for simultaneous keys (e.g., 'CMD+C') and '>' for sequential keys/chords (e.g., 'Super+T>W>S').",
"additionalProperties": {
"type": "object",
"minProperties": 1
Expand All @@ -68,7 +68,7 @@
}
},
{
"description": "When AllowText is not true, restrict shortcut key syntax.",
"description": "When AllowText is not true, restrict shortcut key syntax. Supports '+' for simultaneous keys and '>' for sequential shortcuts (chords).",
"if": {
"not": {
"properties": { "AllowText": { "const": true } },
Expand Down Expand Up @@ -120,6 +120,10 @@
"General": {
"CMD+C": { "description": "Copy selected item" },
"CMD+Right": { "description": "Move cursor to end of line" }
},
"Sequential Shortcuts": {
"Super+T>W>S": { "description": "Multi-sequence shortcut example" },
"CTRL+K>CTRL+C": { "description": "Add line comment" }
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ requires-python = ">=3.9"
dependencies = ["jinja2>=3.1.6", "python-dotenv>=1.1.1", "ruamel-yaml>=0.18.14"]

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



[tool.ruff]
Expand Down
11 changes: 11 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--strict-markers
--disable-warnings

32 changes: 25 additions & 7 deletions src/generate_cheatsheet.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from ruamel.yaml import YAML
import sys
import os
from validate_yaml import validate_yaml, lint_yaml
import re
import sys
from pathlib import Path

from dotenv import load_dotenv
from template_renderer import render_template
from ruamel.yaml import YAML

from logger import get_logger
from pathlib import Path
from template_renderer import render_template
from validate_yaml import lint_yaml, validate_yaml

yaml_safe = YAML(typ="safe")
yaml_rw = YAML()
Expand Down Expand Up @@ -56,17 +59,32 @@ def replace_shortcut_names(shortcut, system_mappings):
try:
processed_parts = []
i = 0
shortcut = re.sub(r"(\+|\>)\s*(\+|\>)", r"\g<1>\g<2>", shortcut)

while i < len(shortcut):
if shortcut[i] == "+":
if i + 1 < len(shortcut) and shortcut[i + 1] == "+":
processed_parts.append("+")
processed_parts.append("<sep>+")
i += 2
elif i + 1 < len(shortcut) and shortcut[i + 1] == ">":
processed_parts.append("<sep>>")
i += 2
else:
processed_parts.append("<sep>")
i += 1
elif shortcut[i] == ">":
if i + 1 < len(shortcut) and shortcut[i + 1] == ">":
processed_parts.append("<seq>>")
i += 2
elif i + 1 < len(shortcut) and shortcut[i + 1] == "+":
processed_parts.append("<seq>+")
i += 2
else:
processed_parts.append("<seq>")
i += 1
else:
current_part = ""
while i < len(shortcut) and shortcut[i] != "+":
while i < len(shortcut) and shortcut[i] not in ("+", ">"):
current_part += shortcut[i]
i += 1
if current_part.strip():
Expand Down
2 changes: 1 addition & 1 deletion src/layouts/keyboard_layouts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ ES:
"´",
"Enter",
]
- ["Shift", "Ç", "Z", "X", "C", "V", "B", "N", "M", ",", ".", "-", "Shift"]
- ["Shift", ">", "Z", "X", "C", "V", "B", "N", "M", ",", ".", "-", "Shift"]
- ["Ctrl", "Alt", "Cmd", "Space", "Cmd", "Alt", "Ctrl"]

DVORAK:
Expand Down
65 changes: 65 additions & 0 deletions src/templates/cheatsheets/assets/cheatsheets.css
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ body.dark-mode .shortcut-key .key-part {
margin-left: 0.3em;
}

.shortcut-key .sequence-separator {
white-space-collapse: preserve;
}

body.dark-mode .shortcut-key .separator {
color: var(--mocha-subtext0);
}
Expand Down Expand Up @@ -338,6 +342,67 @@ body.dark-mode .key.active {
color: var(--mocha-base);
box-shadow: 0 0 8px var(--mocha-green);
}
.key.active-step-1 {
background: var(--latte-green);
color: var(--latte-base);
box-shadow: 0 0 8px var(--latte-green);
}
body.dark-mode .key.active-step-1 {
background: var(--mocha-green);
color: var(--mocha-base);
box-shadow: 0 0 8px var(--mocha-green);
}
.key.active-step-2 {
background: var(--latte-sapphire);
color: var(--latte-base);
box-shadow: 0 0 8px var(--latte-sapphire);
}
body.dark-mode .key.active-step-2 {
background: var(--mocha-sapphire);
color: var(--mocha-base);
box-shadow: 0 0 8px var(--mocha-sapphire);
}
.key.active-step-3 {
background: var(--latte-peach);
color: var(--latte-base);
box-shadow: 0 0 8px var(--latte-peach);
}
body.dark-mode .key.active-step-3 {
background: var(--mocha-peach);
color: var(--mocha-base);
box-shadow: 0 0 8px var(--mocha-peach);
}
.key.active-step-4 {
background: var(--latte-mauve);
color: var(--latte-base);
box-shadow: 0 0 8px var(--latte-mauve);
}
body.dark-mode .key.active-step-4 {
background: var(--mocha-mauve);
color: var(--mocha-base);
box-shadow: 0 0 8px var(--mocha-mauve);
}
.key.active-step-5 {
background: var(--latte-lavender);
color: var(--latte-base);
box-shadow: 0 0 8px var(--latte-lavender);
}
body.dark-mode .key.active-step-5 {
background: var(--mocha-lavender);
color: var(--mocha-base);
box-shadow: 0 0 8px var(--mocha-lavender);
}
/* Default style for steps greater than 5 */
.key[class*="active-step-"]:not(.active-step-1):not(.active-step-2):not(.active-step-3):not(.active-step-4):not(.active-step-5) {
background: var(--latte-yellow);
color: var(--latte-base);
box-shadow: 0 0 8px var(--latte-yellow);
}
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) {
background: var(--mocha-yellow);
color: var(--mocha-base);
box-shadow: 0 0 8px var(--mocha-yellow);
}
.key__wide {
width: 75px;
}
Expand Down
10 changes: 7 additions & 3 deletions src/templates/cheatsheets/components/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,13 @@ <h2>{{ section }}</h2>
{% if allow_text %}
{{ shortcut }}
{% else %}
{% for key in shortcut.split('<sep>') %}
<span class="key-part">{{ key | safe }}</span>
{% if not loop.last %}<span class="separator">+</span>{% endif %}
{% set key_groups = shortcut.split('<seq>') %}
{% for group in key_groups %}
{% if loop.index > 1 %}<span class="sequence-separator"> </span>{% endif %}
{% for key in group.split('<sep>') %}
<span class="key-part">{{ key | safe }}</span>
{% if not loop.last %}<span class="separator">+</span>{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
</div>
Expand Down
70 changes: 59 additions & 11 deletions src/templates/cheatsheets/scripts/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,21 @@
updateDarkModeToggle();
adjustLayout();

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

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

shortcutParts.forEach((keyToFind) => {
function highlightChordsPart(chordsPart, stepClass) {
chordsPart.forEach((keyToFind) => {
keyToFind = keyToFind.toLowerCase();
const keyElements = document.querySelectorAll(".key");

Expand All @@ -192,18 +198,60 @@
keyLabel === keyToFind ||
(keyToFind === "cmd" && (dataKey === "cmd" || dataKey === "win" || dataKey === "super"))
) {
element.classList.add("active");
element.classList.add(stepClass);
}
});
});
}

// Track the current animation timeout so we can cancel/reset it
let sequentialShortcutTimeout = null;

function animateChords(chords) {
// Cancel any ongoing animation
if (sequentialShortcutTimeout) {
clearTimeout(sequentialShortcutTimeout);
sequentialShortcutTimeout = null;
}

clearActiveKeyClasses();

const animationDelay = 500;
let currentStep = 0;

const animateStep = () => {
if (currentStep < chords.length) {
const stepClass = `active-step-${currentStep + 1}`;
highlightChordsPart(chords[currentStep], stepClass);
currentStep++;
sequentialShortcutTimeout = setTimeout(animateStep, animationDelay);
} else {
sequentialShortcutTimeout = null;
}
};

animateStep();
}

document.querySelectorAll(".shortcut").forEach((shortcut) => {
{% if not allow_text %}
shortcut.addEventListener("click", function() {
// Get the text content and replace <sep> with +
const shortcutKey = this.querySelector(".shortcut-key").textContent.replace(/\s*\+\s*/g, '+');
highlightKeys(shortcutKey);

const isKeyPart = (x) => {return x.classList.contains("key-part")}
const isSequenceSeparator = (x) => {return x.classList.contains("sequence-separator")}

const chords=[[]];
let chords_idx=0;
this.querySelectorAll(".shortcut-key span").forEach(x => {
if (isKeyPart(x)) {
chords[chords_idx].push(x.textContent.trim())
};
if (isSequenceSeparator(x)) {
chords.push([]);
chords_idx++;
}
})
animateChords(chords);
});
{% endif %}
});
Expand Down
2 changes: 2 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Tests for KoalaKeys

7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Pytest configuration and fixtures."""

import sys
from pathlib import Path

# Add src directory to Python path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
67 changes: 67 additions & 0 deletions tests/test_replace_shortcut_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Tests for the replace_shortcut_names function."""

import pytest

from generate_cheatsheet import replace_shortcut_names


def test_three_keys():
"""Test Ctrl+Shift+A -> Ctrl<sep>Shift<sep>A"""
result = replace_shortcut_names("Ctrl+Shift+A", {})
expected = "Ctrl<sep>Shift<sep>A"
assert result == expected


def test_plus_key():
"""Test Ctrl++ -> Ctrl<sep>+"""
result = replace_shortcut_names("Ctrl++", {})
expected = "Ctrl<sep>+"
assert result == expected


def test_angle_bracket():
"""Test CTRL+> -> CTRL<sep>>"""
result = replace_shortcut_names("CTRL+>", {})
expected = "CTRL<sep>>"
assert result == expected


def test_simple_chord():
"""Test Super+T>W>S -> Super<sep>T<seq>W<seq>S"""
result = replace_shortcut_names("Super+T>W>S", {})
expected = "Super<sep>T<seq>W<seq>S"
assert result == expected


def test_composed_chord():
"""Test CTRL+C>CTRL+K -> CTRL<sep>C<seq>CTRL<sep>K"""
result = replace_shortcut_names("CTRL+C>CTRL+K", {})
expected = "CTRL<sep>C<seq>CTRL<sep>K"
assert result == expected


def test_angle_bracket_in_chord():
"""Test CTRL>> -> CTRL<seq>>"""
result = replace_shortcut_names("CTRL>>", {})
expected = "CTRL<seq>>"
assert result == expected


def test_plus_key_in_chord():
"""Test CTRL>+ -> CTRL<seq>+"""
result = replace_shortcut_names("CTRL>+", {})
expected = "CTRL<seq>+"
assert result == expected


def test_spaces():
assert replace_shortcut_names("Ctrl + C", {}) == "Ctrl<sep>C"
assert replace_shortcut_names("Ctrl + Shift + A", {}) == "Ctrl<sep>Shift<sep>A"
assert replace_shortcut_names("Ctrl + +", {}) == "Ctrl<sep>+"
assert replace_shortcut_names("CTRL + >", {}) == "CTRL<sep>>"
assert replace_shortcut_names("Super + T > W > S", {}) == "Super<sep>T<seq>W<seq>S"
assert replace_shortcut_names("CTRL + C > CTRL + K", {}) == "CTRL<sep>C<seq>CTRL<sep>K"
assert replace_shortcut_names("CTRL > >", {}) == "CTRL<seq>>"
assert replace_shortcut_names("CTRL > +", {}) == "CTRL<seq>+"


Loading