Compare commits
11 Commits
cc92d0ce49
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d4a033e500 | |||
|
|
163f8dda57 | ||
|
|
2d067226b9 | ||
|
|
73aa54d7bd | ||
|
|
b092047ca6 | ||
|
|
a058665b5b | ||
|
|
876014a7b7 | ||
|
|
9564900909 | ||
|
|
782c7182f2 | ||
|
|
f9acdef3fe | ||
|
|
732982e66c |
44
CHANGELOG.md
Normal file
44
CHANGELOG.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to finrod are documented here.
|
||||||
|
|
||||||
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-06-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p` (vim's internal register), undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard.
|
||||||
|
- `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor.
|
||||||
|
- On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Editor font-size pinned to `1rem` to match the output pane (CodeMirror 6 ships smaller by default).
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-09
|
||||||
|
|
||||||
|
First release. Stateless translator UI that proxies streaming requests to
|
||||||
|
Ollama on radagast.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- FastAPI backend with three endpoints:
|
||||||
|
- `GET /api/models` — filtered (`gemma,aya` by default) list from Ollama's `/api/tags`
|
||||||
|
- `POST /api/translate` — streams Ollama's NDJSON `/api/generate` response back to the browser
|
||||||
|
- `GET /api/loaded` — proxies Ollama's `/api/ps` for the currently-loaded model
|
||||||
|
- `GET /healthz` — for Kubernetes probes
|
||||||
|
- Single-page UI:
|
||||||
|
- Source / target / model dropdowns (30+ languages, "Auto-detect" source)
|
||||||
|
- Token-by-token streaming into the output pane with a blinking caret
|
||||||
|
- Live status line: spinner while waiting for first token, pulsing dot during streaming, elapsed time and token count
|
||||||
|
- Glowing border on the output pane during streaming
|
||||||
|
- Loaded-model indicator (`● ` prefix in dropdown, "Hot: … — unloads in Xm" status line); refreshes on page load, on translation completion, and 500 ms after typing pauses
|
||||||
|
- ⌘/Ctrl-Enter to translate from the input
|
||||||
|
- Dark mode, inline SVG favicon (Star of Fëanor)
|
||||||
|
- Dockerfile: two-stage `uv` build, slim Python 3.13 runtime
|
||||||
|
- `build.sh`: multi-arch (amd64 + arm64) manual push to Scaleway, tags `:latest`, `:<git-short-sha>`, and `:<pyproject-version>`
|
||||||
|
- Cluster deploy via the `valinor` repo at `apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Lock against public PyPI explicitly — the workstation's global `uv` config had pointed `uv.lock` sources at the dexterenergy private artifact registry, which 401'd inside Docker
|
||||||
27
README.md
27
README.md
@@ -47,14 +47,29 @@ Then on every change:
|
|||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That builds `linux/amd64,linux/arm64` and pushes `rg.nl-ams.scw.cloud/valinor/finrod:{latest, <git-short-sha>}`. Take note of the short SHA it prints — that's what you pin in valinor.
|
That builds `linux/amd64,linux/arm64` and pushes three tags:
|
||||||
|
- `:latest` — moving pointer
|
||||||
|
- `:<git-short-sha>` — immutable, traceable
|
||||||
|
- `:<pyproject-version>` (e.g. `:0.1.0`) — semver
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build:
|
Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build, bump `tag:` in `valinor/apps/finrod/values.yaml` to the new semver on a branch → PR → merge → ArgoCD syncs.
|
||||||
|
|
||||||
1. `./build.sh` here — get the SHA
|
## Release workflow
|
||||||
2. Bump `tag:` in `valinor/apps/finrod/values.yaml` to that SHA on a branch
|
|
||||||
3. Merge → ArgoCD syncs the new image
|
|
||||||
|
|
||||||
Using `:latest` works for the first deploy (the pull policy is `Always`) but pinning to a SHA on each rollout gives you trivial rollback via git revert.
|
This project uses [Semantic Versioning](https://semver.org/) and keeps a human-edited [CHANGELOG.md](./CHANGELOG.md).
|
||||||
|
|
||||||
|
To cut a release:
|
||||||
|
|
||||||
|
1. On a branch, edit `CHANGELOG.md`: move accumulated `[Unreleased]` notes into a new `## [X.Y.Z] - YYYY-MM-DD` section.
|
||||||
|
2. Bump `version` in `pyproject.toml` to `X.Y.Z`.
|
||||||
|
3. PR → merge to `main`.
|
||||||
|
4. Tag the merge commit:
|
||||||
|
```sh
|
||||||
|
git checkout main && git pull
|
||||||
|
git tag -a vX.Y.Z -m "vX.Y.Z"
|
||||||
|
git push origin vX.Y.Z
|
||||||
|
```
|
||||||
|
5. `./build.sh` — the image now also gets `:X.Y.Z` (read from `pyproject.toml`).
|
||||||
|
6. In `valinor`, bump `apps/finrod/values.yaml` `tag:` from the previous version to `X.Y.Z` on a branch → PR → ArgoCD picks it up.
|
||||||
|
|||||||
17
app.py
17
app.py
@@ -49,6 +49,23 @@ async def list_models() -> list[str]:
|
|||||||
return sorted(names)
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/loaded")
|
||||||
|
async def loaded_models() -> dict[str, list[dict[str, str | None]]]:
|
||||||
|
"""Models currently resident in Ollama's memory (from /api/ps)."""
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
try:
|
||||||
|
r = await client.get(f"{OLLAMA_URL}/api/ps")
|
||||||
|
r.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(502, f"Ollama unreachable: {e}") from e
|
||||||
|
return {
|
||||||
|
"models": [
|
||||||
|
{"name": m["name"], "expires_at": m.get("expires_at")}
|
||||||
|
for m in r.json().get("models", [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/translate")
|
@app.post("/api/translate")
|
||||||
async def translate(req: TranslateRequest) -> StreamingResponse:
|
async def translate(req: TranslateRequest) -> StreamingResponse:
|
||||||
text = req.text.strip()
|
text = req.text.strip()
|
||||||
|
|||||||
23
build.sh
23
build.sh
@@ -9,20 +9,24 @@
|
|||||||
# docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY>
|
# docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY>
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./build.sh # tags :latest and :<short-sha>
|
# ./build.sh
|
||||||
# TAG=v0.2.0 ./build.sh # additional explicit tag
|
#
|
||||||
|
# Always tags:
|
||||||
|
# :latest — moving pointer
|
||||||
|
# :<git-short-sha> — immutable, traceable to a commit
|
||||||
|
# :<pyproject-version> — semver from pyproject.toml (e.g. :0.1.0)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}"
|
REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}"
|
||||||
IMAGE="${REGISTRY}/finrod"
|
IMAGE="${REGISTRY}/finrod"
|
||||||
SHA="$(git rev-parse --short HEAD)"
|
SHA="$(git rev-parse --short HEAD)"
|
||||||
TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}")
|
VERSION="$(awk -F\" '/^version =/ {print $2; exit}' pyproject.toml)"
|
||||||
if [[ -n "${TAG:-}" ]]; then
|
: "${VERSION:?Could not read version from pyproject.toml}"
|
||||||
TAGS+=(-t "${IMAGE}:${TAG}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -t "${IMAGE}:${VERSION}")
|
||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
@@ -31,4 +35,7 @@ docker buildx build \
|
|||||||
.
|
.
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Pushed: ${IMAGE}:{latest,${SHA}${TAG:+,${TAG}}}"
|
echo "Pushed:"
|
||||||
|
echo " ${IMAGE}:latest"
|
||||||
|
echo " ${IMAGE}:${SHA}"
|
||||||
|
echo " ${IMAGE}:${VERSION}"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "finrod"
|
name = "finrod"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Translator UI backed by Ollama"
|
description = "Translator UI backed by Ollama"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -132,6 +132,53 @@
|
|||||||
0%, 100% { opacity: 0.3; transform: scale(0.85); }
|
0%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||||||
50% { opacity: 1; transform: scale(1.15); }
|
50% { opacity: 1; transform: scale(1.15); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hot-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.25rem 0 0.75rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
.hot-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e07a3e;
|
||||||
|
box-shadow: 0 0 6px #e07a3e;
|
||||||
|
}
|
||||||
|
.hot-status.idle { opacity: 0.55; }
|
||||||
|
.hot-status.idle .hot-dot {
|
||||||
|
background: var(--border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror editor host — matches .output styling so input & output align */
|
||||||
|
.cm-editor {
|
||||||
|
min-height: 180px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 1rem; /* match .output's inherited body size — CM6 defaults are smaller */
|
||||||
|
}
|
||||||
|
.cm-editor.cm-focused {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.cm-scroller { padding: 0.6rem 0.75rem; }
|
||||||
|
.cm-content { caret-color: var(--accent); }
|
||||||
|
/* vim's command-line / status hint inherits readable colors */
|
||||||
|
.cm-vim-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--fg);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -154,10 +201,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="hot-status" class="hot-status idle">
|
||||||
|
<span class="hot-dot"></span>
|
||||||
|
<span id="hot-status-text">No model loaded</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panes">
|
<div class="panes">
|
||||||
<div>
|
<div>
|
||||||
<label for="input">Input</label>
|
<label for="input">Input</label>
|
||||||
<textarea id="input" placeholder="Type or paste text to translate…"></textarea>
|
<div id="input"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="output">Translation</label>
|
<label for="output">Translation</label>
|
||||||
@@ -169,7 +221,12 @@
|
|||||||
<div id="meta" class="meta"></div>
|
<div id="meta" class="meta"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
|
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
|
||||||
|
import { EditorState } from "https://esm.sh/@codemirror/state@6";
|
||||||
|
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6";
|
||||||
|
import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
||||||
"French", "German", "Italian", "Dutch", "Polish", "Russian",
|
"French", "German", "Italian", "Dutch", "Polish", "Russian",
|
||||||
@@ -182,10 +239,42 @@
|
|||||||
const sourceSel = document.getElementById("source");
|
const sourceSel = document.getElementById("source");
|
||||||
const targetSel = document.getElementById("target");
|
const targetSel = document.getElementById("target");
|
||||||
const modelSel = document.getElementById("model");
|
const modelSel = document.getElementById("model");
|
||||||
const input = document.getElementById("input");
|
|
||||||
const output = document.getElementById("output");
|
const output = document.getElementById("output");
|
||||||
const goBtn = document.getElementById("go");
|
const goBtn = document.getElementById("go");
|
||||||
const meta = document.getElementById("meta");
|
const meta = document.getElementById("meta");
|
||||||
|
const hotStatus = document.getElementById("hot-status");
|
||||||
|
const hotStatusText = document.getElementById("hot-status-text");
|
||||||
|
|
||||||
|
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
|
||||||
|
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
let typingTimer;
|
||||||
|
const editor = new EditorView({
|
||||||
|
parent: document.getElementById("input"),
|
||||||
|
state: EditorState.create({
|
||||||
|
extensions: [
|
||||||
|
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
|
||||||
|
// takes precedence. Skipped on touch devices — modal editing without
|
||||||
|
// a hardware Esc key on a virtual keyboard is unusable, and IME
|
||||||
|
// composition fights the vim keymap.
|
||||||
|
...(isCoarsePointer ? [] : [vim()]),
|
||||||
|
history(),
|
||||||
|
keymap.of([
|
||||||
|
{ key: "Mod-Enter", run: () => { translate(); return true; } },
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
]),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
placeholder("Type or paste text to translate…"),
|
||||||
|
EditorView.updateListener.of(u => {
|
||||||
|
if (u.docChanged) {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(refreshLoaded, 500);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const inputText = () => editor.state.doc.toString();
|
||||||
|
|
||||||
function fillLangs() {
|
function fillLangs() {
|
||||||
const auto = document.createElement("option");
|
const auto = document.createElement("option");
|
||||||
@@ -246,7 +335,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function translate() {
|
async function translate() {
|
||||||
const text = input.value.trim();
|
const text = inputText().trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
goBtn.disabled = true;
|
goBtn.disabled = true;
|
||||||
goBtn.textContent = "Translating…";
|
goBtn.textContent = "Translating…";
|
||||||
@@ -313,6 +402,7 @@
|
|||||||
output.textContent = acc;
|
output.textContent = acc;
|
||||||
clearInterval(ticker);
|
clearInterval(ticker);
|
||||||
setMeta({ phase: "done", model, seconds: dt, tokens });
|
setMeta({ phase: "done", model, seconds: dt, tokens });
|
||||||
|
refreshLoaded();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearInterval(ticker);
|
clearInterval(ticker);
|
||||||
output.innerHTML = `<span class="error">${e.message || e}</span>`;
|
output.innerHTML = `<span class="error">${e.message || e}</span>`;
|
||||||
@@ -324,13 +414,116 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRemaining(expiresAt) {
|
||||||
|
if (!expiresAt) return null;
|
||||||
|
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||||
|
if (!isFinite(ms) || ms <= 0) return null;
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
const mins = Math.floor(totalSec / 60);
|
||||||
|
const secs = totalSec % 60;
|
||||||
|
if (mins >= 60) {
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
return `${hrs}h${m ? m + "m" : ""}`;
|
||||||
|
}
|
||||||
|
if (mins > 0) return `${mins}m${secs ? secs + "s" : ""}`;
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLoaded(isInitial = false) {
|
||||||
|
let models = [];
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/loaded");
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json();
|
||||||
|
models = j.models || [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* leave previous state if Ollama is briefly unreachable */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
|
||||||
|
|
||||||
|
// Update dropdown options. Use textContent (option doesn't render HTML).
|
||||||
|
const optionValues = Array.from(modelSel.options, o => o.value);
|
||||||
|
for (const opt of modelSel.options) {
|
||||||
|
if (loadedMap.has(opt.value)) {
|
||||||
|
opt.textContent = `● ${opt.value}`;
|
||||||
|
} else {
|
||||||
|
opt.textContent = opt.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the very first refresh after page load, prefer a hot model as the
|
||||||
|
// dropdown default — skipping disk reload pain. Only on initial load,
|
||||||
|
// so the user's manual selection isn't yanked away while they type.
|
||||||
|
if (isInitial) {
|
||||||
|
const hot = models.find(m => optionValues.includes(m.name));
|
||||||
|
if (hot) modelSel.value = hot.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status line.
|
||||||
|
if (models.length === 0) {
|
||||||
|
hotStatus.classList.add("idle");
|
||||||
|
hotStatusText.textContent = "No model loaded";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hotStatus.classList.remove("idle");
|
||||||
|
const parts = models.map(m => {
|
||||||
|
const remaining = formatRemaining(m.expires_at);
|
||||||
|
return remaining ? `${m.name} — unloads in ${remaining}` : m.name;
|
||||||
|
});
|
||||||
|
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
goBtn.addEventListener("click", translate);
|
goBtn.addEventListener("click", translate);
|
||||||
input.addEventListener("keydown", (e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
|
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
|
||||||
|
// Tab). Two paths kept in sync:
|
||||||
|
// 1. Document keydown listener — fires when focus is on a select / button
|
||||||
|
// 2. Vim normal-mode mapping (below) — fires when focus is in the editor
|
||||||
|
// Together they make Shift-H/L work everywhere, including escaping the editor.
|
||||||
|
const focusables = [
|
||||||
|
{ el: sourceSel, focus: () => sourceSel.focus() },
|
||||||
|
{ el: targetSel, focus: () => targetSel.focus() },
|
||||||
|
{ el: modelSel, focus: () => modelSel.focus() },
|
||||||
|
{ el: editor.contentDOM, focus: () => editor.focus() },
|
||||||
|
{ el: goBtn, focus: () => goBtn.focus() },
|
||||||
|
];
|
||||||
|
function moveFocus(delta) {
|
||||||
|
const ae = document.activeElement;
|
||||||
|
let idx = focusables.findIndex(f => f.el === ae || f.el.contains?.(ae));
|
||||||
|
if (idx === -1) {
|
||||||
|
focusables[delta > 0 ? 0 : focusables.length - 1].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = focusables[(idx + delta + focusables.length) % focusables.length];
|
||||||
|
next.focus();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (!e.shiftKey) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k !== "h" && k !== "l") return;
|
||||||
|
// Inside the editor, vim's mapCommand handles H/L (see below). Letting
|
||||||
|
// the event bubble here would double-jump.
|
||||||
|
if (editor.dom.contains(document.activeElement)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(k === "l" ? 1 : -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Override vim's H/L (normally "viewport top/bottom") so they exit the
|
||||||
|
// editor to the previous/next control, matching the Shift-H/L contract
|
||||||
|
// the document handler uses elsewhere.
|
||||||
|
if (!isCoarsePointer) {
|
||||||
|
Vim.defineAction("finrodFocusPrev", () => moveFocus(-1));
|
||||||
|
Vim.defineAction("finrodFocusNext", () => moveFocus(1));
|
||||||
|
Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" });
|
||||||
|
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
|
||||||
|
}
|
||||||
|
|
||||||
fillLangs();
|
fillLangs();
|
||||||
loadModels();
|
loadModels().then(() => refreshLoaded(true));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user