Compare commits

9 Commits

Author SHA1 Message Date
d4a033e500 Merge pull request 'v0.2.0: vim bindings, Shift-H/L focus nav, hot-model default' (#3) from feature/vim-bindings into main 2026-06-09 15:22:41 +00:00
João Pedro Battistella Nadas
163f8dda57 release: v0.2.0 2026-06-09 17:22:26 +02:00
João Pedro Battistella Nadas
2d067226b9 revert: drop clipboard yank/paste and modal dropdowns
The vim clipboard integration was unreliable across browsers
(library "+" register broken on Firefox; custom navigator.clipboard
actions worked on Chrome but hit chip prompts and Firefox config
edge cases). The modal dropdown layer added more state than it
saved keystrokes. Pulling both back out.

What remains of the vim work:
  - CodeMirror + @replit/codemirror-vim in the input buffer
    (i/Esc, hjkl, word/line motions, yy/p via vim's internal
    register, undo, /, :)
  - Shift-H / Shift-L focus navigation across controls, including
    the H/L override inside the editor so it works from any focus
2026-06-09 17:07:01 +02:00
João Pedro Battistella Nadas
73aa54d7bd fix: Firefox clipboard, flip dropdown default to normal mode
- Replace Vim.map("...", "\"+...", ...) with custom Vim.defineAction
  handlers calling navigator.clipboard directly. The library's "+"
  register implementation doesn't reliably write on Firefox.
  Limitation: only yy/Y/visual-y reach system clipboard; bare
  y<motion> still uses vim's internal register.
- Flip dropdown modal: default NORMAL (j/k arrows), `/` or `i`
  enters search. Listener moved to document capture phase so closed
  dropdowns receive keydown on Firefox.
2026-06-09 16:53:11 +02:00
João Pedro Battistella Nadas
b092047ca6 feat: y/Y system clipboard, modal vim dropdowns
- y/Y/yy and visual y now route through vim's "+" register, which
  copies to the system clipboard via the synchronous document
  .execCommand("copy") path — no permission prompt.
- p/P also remapped to "+ register for symmetry; replaces my custom
  navigator.clipboard.readText() action. Browser may still show a
  permission chip on first paste; granting once makes it silent.
- Source / target / model dropdowns get modal vim-style nav:
  default is native typeahead, Esc → normal mode (accent border),
  j/k move selectedIndex, i and / return to search, blur resets.
  Disabled on coarse-pointer devices.
2026-06-09 16:47:33 +02:00
João Pedro Battistella Nadas
a058665b5b feat: vim p/P paste from system clipboard
Vim's default p/P pastes from its internal unnamed register, which
is useless when the text to translate lives in another app's
clipboard. Override both in normal mode to read navigator.clipboard
.readText() and insert at (after) or before the cursor.

Caveats: requires browser clipboard permission (prompted on first
use), HTTPS or localhost. Both already satisfied — finrod is served
over the wildcard TLS cert.
2026-06-09 16:41:52 +02:00
João Pedro Battistella Nadas
876014a7b7 fix: Shift-H/L also exit the editor in vim normal mode
The previous version only handled Shift-H/L when focus was outside
the editor; inside vim normal mode they were viewport top/bottom
and required Esc+Tab to escape. Now Vim.mapCommand overrides H/L
in normal-mode context to call the same moveFocus(±1) helper, so
the shortcut works uniformly regardless of focus position.
2026-06-09 16:39:05 +02:00
João Pedro Battistella Nadas
9564900909 feat: editor font-size match, hot model default, Shift-H/L nav
- .cm-editor pinned to font-size 1rem so the input matches the
  output pane (CodeMirror 6 defaults smaller)
- refreshLoaded(isInitial=true) on first load picks the hot model
  as the dropdown default — avoids the 5-15s disk reload on the
  first translation. Subsequent refreshes don't override the user's
  manual selection.
- document-level Shift-H / Shift-L cycle focus across controls
  (source → target → model → editor → button), suppressed when
  focus is inside CodeMirror so vim's viewport H/L still work.
2026-06-09 16:22:20 +02:00
João Pedro Battistella Nadas
782c7182f2 feat: vim bindings in the input via CodeMirror 6
- Replace the plain <textarea> with a CodeMirror 6 EditorView that
  has @replit/codemirror-vim attached. Loaded from esm.sh, so no
  build step — finrod stays no-bundler.
- vim() extension is conditionally omitted on coarse-pointer devices
  (phones, tablets) where modal editing without a hardware Esc key
  is unusable and IME composition fights the keymap.
- Ctrl-/Cmd-Enter shortcut and the typing-debounce that refreshes
  the "Hot:" indicator both migrate to CodeMirror's keymap and
  updateListener APIs respectively.
- Editor styled to match the existing .output pane.

CHANGELOG: note under [Unreleased].
2026-06-09 16:15:36 +02:00
3 changed files with 127 additions and 15 deletions

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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>