Merge pull request 'v0.2.0: vim bindings, Shift-H/L focus nav, hot-model default' (#3) from feature/vim-bindings into main
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -7,6 +7,16 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "finrod"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Translator UI backed by Ollama"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -155,6 +155,30 @@
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -185,7 +209,7 @@
|
||||
<div class="panes">
|
||||
<div>
|
||||
<label for="input">Input</label>
|
||||
<textarea id="input" placeholder="Type or paste text to translate…"></textarea>
|
||||
<div id="input"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="output">Translation</label>
|
||||
@@ -197,7 +221,12 @@
|
||||
<div id="meta" class="meta"></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 = [
|
||||
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
||||
"French", "German", "Italian", "Dutch", "Polish", "Russian",
|
||||
@@ -210,13 +239,43 @@
|
||||
const sourceSel = document.getElementById("source");
|
||||
const targetSel = document.getElementById("target");
|
||||
const modelSel = document.getElementById("model");
|
||||
const input = document.getElementById("input");
|
||||
const output = document.getElementById("output");
|
||||
const goBtn = document.getElementById("go");
|
||||
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() {
|
||||
const auto = document.createElement("option");
|
||||
auto.value = "auto";
|
||||
@@ -276,7 +335,7 @@
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
const text = input.value.trim();
|
||||
const text = inputText().trim();
|
||||
if (!text) return;
|
||||
goBtn.disabled = true;
|
||||
goBtn.textContent = "Translating…";
|
||||
@@ -371,7 +430,7 @@
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
async function refreshLoaded() {
|
||||
async function refreshLoaded(isInitial = false) {
|
||||
let models = [];
|
||||
try {
|
||||
const r = await fetch("/api/loaded");
|
||||
@@ -387,6 +446,7 @@
|
||||
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}`;
|
||||
@@ -395,6 +455,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
@@ -409,19 +477,53 @@
|
||||
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
let typingTimer;
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(refreshLoaded, 500);
|
||||
goBtn.addEventListener("click", 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);
|
||||
});
|
||||
|
||||
goBtn.addEventListener("click", translate);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
|
||||
});
|
||||
// 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();
|
||||
loadModels().then(refreshLoaded);
|
||||
loadModels().then(() => refreshLoaded(true));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user