buninu-browser 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/jsmdcui.txt +22 -0
- package/README.md +19 -4
- package/frontends/wayland/LICENSE-land +21 -0
- package/frontends/wayland/README.md +123 -0
- package/frontends/wayland/casty/LICENSE +21 -0
- package/frontends/wayland/casty/bookmarks.js +32 -0
- package/frontends/wayland/casty/click.js +96 -0
- package/frontends/wayland/casty/commands.js +61 -0
- package/frontends/wayland/casty/config.js +45 -0
- package/frontends/wayland/casty/download-directory.js +28 -0
- package/frontends/wayland/casty/download.js +49 -0
- package/frontends/wayland/casty/hints.js +334 -0
- package/frontends/wayland/casty/keys.js +56 -0
- package/frontends/wayland/casty/page-commands.js +80 -0
- package/frontends/wayland/casty/prompt-width.js +148 -0
- package/frontends/wayland/casty/urlbar.js +606 -0
- package/frontends/wayland/client.js +88 -0
- package/frontends/wayland/clipboard.js +80 -0
- package/frontends/wayland/index.js +116 -0
- package/frontends/wayland/input.js +56 -0
- package/frontends/wayland/protocol.js +55 -0
- package/frontends/wayland/protocols/wayland.xml +3248 -0
- package/frontends/wayland/protocols/xdg-shell.xml +1415 -0
- package/frontends/wayland/transport.js +120 -0
- package/frontends/wayland/urlbar.js +158 -0
- package/index.js +28 -1
- package/lib/clipboard-command.js +10 -0
- package/lib/clipboard-powershell.js +22 -0
- package/lib/clipboard.js +37 -2
- package/lib/document-types.js +11 -0
- package/lib/headless-shell.js +47 -8
- package/lib/layout/text-layout.js +17 -9
- package/lib/paint/display-list.js +5 -1
- package/lib/qml/README.md +971 -0
- package/lib/qml/builtins.js +700 -0
- package/lib/qml/canvas-dom.js +115 -0
- package/lib/qml/document.js +1176 -0
- package/lib/qml/format.js +9 -0
- package/lib/qml/host.js +324 -0
- package/lib/qml/images.js +199 -0
- package/lib/qml/launcher.js +132 -0
- package/lib/qml/loader.js +517 -0
- package/lib/qml/media-renderer.js +49 -0
- package/lib/qml/media.js +86 -0
- package/lib/qml/models.js +622 -0
- package/lib/qml/parser.js +381 -0
- package/lib/qml/qs +5 -0
- package/lib/qml/qscasty +3 -0
- package/lib/qml/qsheadless +3 -0
- package/lib/qml/qswayland +3 -0
- package/lib/qml/reactive.js +159 -0
- package/lib/qml/runtime.js +3381 -0
- package/lib/qml/shapes.js +243 -0
- package/lib/qml/text.js +109 -0
- package/lib/qml/textures.js +123 -0
- package/lib/qml/wayland-clipboard.js +32 -0
- package/lib/qml/worker-thread.js +70 -0
- package/lib/render-tree/index.js +16 -1
- package/lib/renderer/bindings.js +11 -0
- package/lib/renderer/canvas-2d.js +13 -0
- package/lib/renderer/host.js +31 -6
- package/lib/renderer/main.js +19 -6
- package/lib/renderer/page-realm.js +16 -1
- package/lib/renderer/page-renderer.js +230 -13
- package/lib/renderer/text-input-paint.js +54 -0
- package/lib/renderer/text-input.js +57 -0
- package/lib/style/computed-style.js +2 -2
- package/package.json +1 -1
- package/test/clipboard.test.js +19 -0
- package/test/fixtures/flea-helpers/Dockerfile +36 -0
- package/test/fixtures/flea-helpers/bat.mjs +43 -0
- package/test/fixtures/flea-helpers/bin/bat +3 -0
- package/test/fixtures/flea-helpers/bin/bwrap +10 -0
- package/test/fixtures/flea-helpers/bin/bwrap-real-filter +9 -0
- package/test/fixtures/flea-helpers/bin/glow +3 -0
- package/test/fixtures/flea-helpers/bin/qs +3 -0
- package/test/fixtures/flea-helpers/bin/unsupported +3 -0
- package/test/fixtures/flea-helpers/check-highlighting.mjs +47 -0
- package/test/fixtures/flea-helpers/check-media.mjs +29 -0
- package/test/fixtures/flea-helpers/check-preview.mjs +27 -0
- package/test/fixtures/flea-helpers/check.mjs +36 -0
- package/test/fixtures/flea-helpers/highlight-cases.json +227 -0
- package/test/fixtures/flea-helpers/source-mime.xml +28 -0
- package/test/fixtures/flea-helpers/syntax-compat.mjs +23 -0
- package/test/fixtures/flea-thumbnail-stub/flea-placeholder.thumbnailer +4 -0
- package/test/fixtures/flea-thumbnail-stub/flea-thumbnail-stub +3 -0
- package/test/fixtures/qml/flea/EscapeHint.qml +11 -0
- package/test/fixtures/qml/flea/LICENSE +21 -0
- package/test/fixtures/qml/flea/README.md +25 -0
- package/test/fixtures/qml/flea/Theme.qml +8 -0
- package/test/fixtures/qml/flea/demo.qml +24 -0
- package/test/fixtures/qml/flea/qmldir +3 -0
- package/test/fixtures/qml/flea-theme/LICENSE-Flea +21 -0
- package/test/fixtures/qml/flea-theme/LICENSE-Omarchy +20 -0
- package/test/fixtures/qml/flea-theme/README.md +70 -0
- package/test/fixtures/qml/flea-theme/button.qml +17 -0
- package/test/fixtures/qml/flea-theme/demo.qml +27 -0
- package/test/fixtures/qml/flea-theme/field.qml +21 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/BorderGeometry.js +373 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Color.qml +258 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/I18n.qml +118 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Numbers.js +19 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Plural.js +91 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Style.qml +561 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Util.qml +188 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/qmldir +13 -0
- package/test/fixtures/qml/flea-theme/segment.qml +17 -0
- package/test/fixtures/qml/flea-theme/settings.qml +58 -0
- package/test/fixtures/qml/flea-theme/text-metrics.qml +10 -0
- package/test/fixtures/qml/flea-theme/ui/DialogButton.qml +150 -0
- package/test/fixtures/qml/flea-theme/ui/DialogField.qml +147 -0
- package/test/fixtures/qml/flea-theme/ui/EscapeHint.qml +11 -0
- package/test/fixtures/qml/flea-theme/ui/Glass.qml +114 -0
- package/test/fixtures/qml/flea-theme/ui/Glyph.qml +54 -0
- package/test/fixtures/qml/flea-theme/ui/GlyphSolid.qml +22 -0
- package/test/fixtures/qml/flea-theme/ui/Reveal.qml +54 -0
- package/test/fixtures/qml/flea-theme/ui/SettingsRuler.qml +85 -0
- package/test/fixtures/qml/flea-theme/ui/SettingsSegment.qml +85 -0
- package/test/fixtures/qml/flea-theme/ui/StableModel.qml +89 -0
- package/test/fixtures/qml/flea-theme/ui/Theme.qml +460 -0
- package/test/fixtures/qml/flea-theme/ui/ThemeColors.qml +43 -0
- package/test/fixtures/qml/flea-theme/ui/ViewState.qml +565 -0
- package/test/fixtures/qml/flea-theme/ui/js/Anchor.js +379 -0
- package/test/fixtures/qml/flea-theme/ui/js/AnchorHold.js +162 -0
- package/test/fixtures/qml/flea-theme/ui/js/Archive.js +157 -0
- package/test/fixtures/qml/flea-theme/ui/js/BackReturn.js +110 -0
- package/test/fixtures/qml/flea-theme/ui/js/Buttons.js +54 -0
- package/test/fixtures/qml/flea-theme/ui/js/ColumnMenu.js +111 -0
- package/test/fixtures/qml/flea-theme/ui/js/Columns.js +298 -0
- package/test/fixtures/qml/flea-theme/ui/js/Contrast.js +157 -0
- package/test/fixtures/qml/flea-theme/ui/js/Density.js +44 -0
- package/test/fixtures/qml/flea-theme/ui/js/DirSizes.js +129 -0
- package/test/fixtures/qml/flea-theme/ui/js/ExtThumbs.js +115 -0
- package/test/fixtures/qml/flea-theme/ui/js/Filter.js +284 -0
- package/test/fixtures/qml/flea-theme/ui/js/FolderSorts.js +108 -0
- package/test/fixtures/qml/flea-theme/ui/js/Format.js +305 -0
- package/test/fixtures/qml/flea-theme/ui/js/Icons.js +206 -0
- package/test/fixtures/qml/flea-theme/ui/js/Input.js +39 -0
- package/test/fixtures/qml/flea-theme/ui/js/KeyName.js +64 -0
- package/test/fixtures/qml/flea-theme/ui/js/Keymap.js +370 -0
- package/test/fixtures/qml/flea-theme/ui/js/Kinds.js +145 -0
- package/test/fixtures/qml/flea-theme/ui/js/Lists.js +11 -0
- package/test/fixtures/qml/flea-theme/ui/js/MakeDefault.js +186 -0
- package/test/fixtures/qml/flea-theme/ui/js/Match.js +27 -0
- package/test/fixtures/qml/flea-theme/ui/js/Material.js +109 -0
- package/test/fixtures/qml/flea-theme/ui/js/Menu.js +513 -0
- package/test/fixtures/qml/flea-theme/ui/js/Motion.js +16 -0
- package/test/fixtures/qml/flea-theme/ui/js/Mounts.js +320 -0
- package/test/fixtures/qml/flea-theme/ui/js/MouseNav.js +13 -0
- package/test/fixtures/qml/flea-theme/ui/js/Nav.js +328 -0
- package/test/fixtures/qml/flea-theme/ui/js/Palette.js +33 -0
- package/test/fixtures/qml/flea-theme/ui/js/Places.js +236 -0
- package/test/fixtures/qml/flea-theme/ui/js/Protocols.js +296 -0
- package/test/fixtures/qml/flea-theme/ui/js/RecentMode.js +154 -0
- package/test/fixtures/qml/flea-theme/ui/js/Search.js +396 -0
- package/test/fixtures/qml/flea-theme/ui/js/Settings.js +476 -0
- package/test/fixtures/qml/flea-theme/ui/js/SettingsAbout.js +99 -0
- package/test/fixtures/qml/flea-theme/ui/js/SettingsShelf.js +52 -0
- package/test/fixtures/qml/flea-theme/ui/js/Sort.js +127 -0
- package/test/fixtures/qml/flea-theme/ui/js/StateWords.js +42 -0
- package/test/fixtures/qml/flea-theme/ui/js/Swap.js +166 -0
- package/test/fixtures/qml/flea-theme/ui/js/TextSize.js +107 -0
- package/test/fixtures/qml/flea-theme/ui/js/Thumbs.js +158 -0
- package/test/fixtures/qml/flea-theme/ui/js/UiState.js +438 -0
- package/test/fixtures/qml/flea-theme/ui/js/Update.js +132 -0
- package/test/fixtures/qml/flea-theme/ui/js/UserDirs.js +56 -0
- package/test/fixtures/qml/flea-theme/ui/qmldir +227 -0
- package/test/fixtures/qml/hello.qml +26 -0
- package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabel.qml +2 -0
- package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabelNew.qml +2 -0
- package/test/fixtures/qml/imports/Demo/Toolkit/qmldir +3 -0
- package/test/fixtures/qml/imports/components/Badge.qml +15 -0
- package/test/fixtures/qml/imports/logic/base.js +2 -0
- package/test/fixtures/qml/imports/logic/counter.js +4 -0
- package/test/fixtures/qml/imports/logic/math.js +3 -0
- package/test/fixtures/qml/imports/logic/shared.js +3 -0
- package/test/fixtures/qml/imports/main.qml +21 -0
- package/test/fixtures/qml/imports/theme/Theme.qml +8 -0
- package/test/fixtures/qml/imports/theme/qmldir +2 -0
- package/test/fixtures/qml/multiply.qml +61 -0
- package/test/fixtures/qml-canvas-dom.js +90 -0
- package/test/fixtures/qml-media-audio.js +39 -0
- package/test/helpers/flea-sandbox.js +48 -0
- package/test/helpers/quickshell-fixture.js +125 -0
- package/test/html-clipboard-wayland.test.js +47 -0
- package/test/index.test.js +13 -0
- package/test/manual-casty-without-qml.js +87 -0
- package/test/manual-flea-application.js +49 -0
- package/test/manual-flea-binary.js +62 -0
- package/test/manual-flea-qml.js +9 -0
- package/test/manual-flea-settings.js +40 -0
- package/test/manual-flea-theme.js +40 -0
- package/test/manual-qml-removal.js +46 -0
- package/test/manual-without-qml.js +45 -0
- package/test/qml-canvas-dom.test.js +37 -0
- package/test/qml-clipboard-bridge.test.js +25 -0
- package/test/qml-clipboard-wayland.test.js +40 -0
- package/test/qml-connections-timing.test.js +73 -0
- package/test/qml-dynamic.test.js +401 -0
- package/test/qml-flea-app.test.js +146 -0
- package/test/qml-flea-backend.test.js +78 -0
- package/test/qml-flea-columns-backend.test.js +38 -0
- package/test/qml-flea-compile.test.js +78 -0
- package/test/qml-flea-launcher.test.js +88 -0
- package/test/qml-flea-markdown-backend.test.js +31 -0
- package/test/qml-flea-media.test.js +28 -0
- package/test/qml-flea-navigation-backend.test.js +35 -0
- package/test/qml-flea-navigation-timing.test.js +147 -0
- package/test/qml-flea-preview-backend.test.js +53 -0
- package/test/qml-flea-previews.test.js +163 -0
- package/test/qml-flea-rows.test.js +101 -0
- package/test/qml-flea-textures.test.js +59 -0
- package/test/qml-flea-view-switch.test.js +33 -0
- package/test/qml-flea-wayland.test.js +68 -0
- package/test/qml-flea.test.js +34 -0
- package/test/qml-host.test.js +238 -0
- package/test/qml-imports.test.js +102 -0
- package/test/qml-inherited-handlers.test.js +51 -0
- package/test/qml-input-selection.test.js +66 -0
- package/test/qml-interactions.test.js +309 -0
- package/test/qml-language.test.js +167 -0
- package/test/qml-launcher.test.js +54 -0
- package/test/qml-layout-text.test.js +205 -0
- package/test/qml-media.test.js +63 -0
- package/test/qml-persisted-delegates.test.js +45 -0
- package/test/qml-quickshell-controls.test.js +65 -0
- package/test/qml-quickshell-settings.test.js +62 -0
- package/test/qml-repeater.test.js +72 -0
- package/test/qml-style-updates.test.js +124 -0
- package/test/qml-textures.test.js +66 -0
- package/test/qml-views.test.js +332 -0
- package/test/qml-worker.test.js +69 -0
- package/test/qml.test.js +83 -0
- package/test/renderer-host.test.js +11 -0
- package/test/text-input-pointer.test.js +31 -0
- package/test/text-input-scroll.test.js +29 -0
- package/test/text-input.test.js +47 -0
- package/test/wayland-frontend.test.js +83 -0
- package/test/wayland-urlbar.test.js +38 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
// Address bar / search bar (always visible)
|
|
2
|
+
// Line 1 of the terminal always shows the current URL
|
|
3
|
+
// Alt+L to enter edit mode → Enter to navigate, Escape to cancel
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
// Wayland adapter: no terminal cursor escapes.
|
|
9
|
+
const hideCursor = () => {};
|
|
10
|
+
import { loadConfig, loadJsonFile } from './config.js';
|
|
11
|
+
import { searchBookmarks } from './bookmarks.js';
|
|
12
|
+
import { displayWidth, fitWidth, promptView, visualColToCharIdx } from './prompt-width.js';
|
|
13
|
+
|
|
14
|
+
const PROMPTS = { url: ' > ', cmd: 'cmd> ' };
|
|
15
|
+
const HISTORY_MAX = 100;
|
|
16
|
+
const DOUBLE_CLICK_MS = 400;
|
|
17
|
+
// The sequences terminals send for these keys (normal and application cursor
|
|
18
|
+
// mode, VT220 and rxvt Home/End), as jsmdcui recognizes them.
|
|
19
|
+
const LEFT_KEYS = new Set(['\x1b[D', '\x1bOD', '\x1b[1;5D', '\x1b[5D']);
|
|
20
|
+
const RIGHT_KEYS = new Set(['\x1b[C', '\x1bOC', '\x1b[1;5C', '\x1b[5C']);
|
|
21
|
+
const HOME_KEYS = new Set(['\x1b[H', '\x1bOH', '\x1b[1~', '\x1b[7~']);
|
|
22
|
+
const END_KEYS = new Set(['\x1b[F', '\x1bOF', '\x1b[4~', '\x1b[8~']);
|
|
23
|
+
export const HISTORY_PATH = join(homedir(), '.casty', 'history.json');
|
|
24
|
+
|
|
25
|
+
function hasScheme(str) {
|
|
26
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(str);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLocalAddress(str) {
|
|
30
|
+
return /^(?:localhost|127(?:\.\d{1,3}){3})(?::\d+)?(?:[/?#]|$)/i.test(str)
|
|
31
|
+
|| /^\[::1\](?::\d+)?(?:[/?#]|$)/i.test(str);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isPublicHostname(str) {
|
|
35
|
+
return /^[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}(?::\d+)?(?:[/?#]|$)/.test(str);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toURL(input) {
|
|
39
|
+
if (isLocalAddress(input)) return 'http://' + input;
|
|
40
|
+
if (hasScheme(input)) return input;
|
|
41
|
+
if (isPublicHostname(input)) return 'https://' + input;
|
|
42
|
+
|
|
43
|
+
// /b [query] → bookmark search
|
|
44
|
+
const bm = input.match(/^\/b(?:\s+(.+))?$/);
|
|
45
|
+
if (bm) {
|
|
46
|
+
const results = searchBookmarks(bm[1] || '');
|
|
47
|
+
if (results.length > 0) return results[0].url;
|
|
48
|
+
return null; // No match
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
return config.searchUrl + encodeURIComponent(input);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// padEnd by display width, cut to fit the line
|
|
56
|
+
function padEndByWidth(str, totalW) {
|
|
57
|
+
const fitted = fitWidth(str, totalW);
|
|
58
|
+
return fitted + ' '.repeat(Math.max(0, totalW - displayWidth(fitted)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
62
|
+
|
|
63
|
+
// Entered lines per mode ({ url: [...], cmd: [...] }), oldest first, like
|
|
64
|
+
// jsmdcui's prompt history. file: null keeps it in memory only.
|
|
65
|
+
export class PromptHistory {
|
|
66
|
+
constructor(file = HISTORY_PATH) {
|
|
67
|
+
this.file = file;
|
|
68
|
+
const data = file ? loadJsonFile(file, {}) : {};
|
|
69
|
+
this.entries = {};
|
|
70
|
+
for (const mode of Object.keys(PROMPTS)) {
|
|
71
|
+
this.entries[mode] = Array.isArray(data?.[mode]) ? data[mode].filter(e => typeof e === 'string') : [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
list(mode) { return this.entries[mode]; }
|
|
76
|
+
|
|
77
|
+
add(mode, line) {
|
|
78
|
+
if (!line) return;
|
|
79
|
+
const list = this.entries[mode];
|
|
80
|
+
const index = list.indexOf(line);
|
|
81
|
+
if (index >= 0) list.splice(index, 1);
|
|
82
|
+
list.push(line);
|
|
83
|
+
if (list.length > HISTORY_MAX) list.splice(0, list.length - HISTORY_MAX);
|
|
84
|
+
if (!this.file) return;
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
87
|
+
writeFileSync(this.file, JSON.stringify(this.entries));
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class UrlBar {
|
|
93
|
+
constructor({ history = new PromptHistory(), commandEntry = false } = {}) {
|
|
94
|
+
this.commandEntry = commandEntry;
|
|
95
|
+
this.currentUrl = '';
|
|
96
|
+
this.editing = false;
|
|
97
|
+
this.loading = false;
|
|
98
|
+
this._spinIdx = 0;
|
|
99
|
+
this._status = null;
|
|
100
|
+
this.text = '';
|
|
101
|
+
this.cursor = 0;
|
|
102
|
+
this._savedHead = '';
|
|
103
|
+
this._savedTail = '';
|
|
104
|
+
this.selectionAnchor = null;
|
|
105
|
+
this._drag = null;
|
|
106
|
+
this._resolve = null;
|
|
107
|
+
this._dirty = true; // Needs re-render
|
|
108
|
+
this._spinTimer = null; // Spinner update interval
|
|
109
|
+
this.mode = 'url'; // 'url' (address) or 'cmd' (Ctrl+E command line)
|
|
110
|
+
this.history = history;
|
|
111
|
+
this._historyIndex = 0;
|
|
112
|
+
this._savedInput = '';
|
|
113
|
+
this._lastClick = { time: 0, zone: null };
|
|
114
|
+
this._scrollX = 0; // Horizontal scroll of the edit line, as in jsmdcui
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get prompt() { return PROMPTS[this.mode]; }
|
|
118
|
+
get selectionRange() {
|
|
119
|
+
return this.selectionAnchor === null || this.selectionAnchor === this.cursor ? null
|
|
120
|
+
: [Math.min(this.selectionAnchor, this.cursor), Math.max(this.selectionAnchor, this.cursor)];
|
|
121
|
+
}
|
|
122
|
+
get selectedText() {
|
|
123
|
+
const range = this.selectionRange;
|
|
124
|
+
return range ? [...this.text].slice(...range).join('') : '';
|
|
125
|
+
}
|
|
126
|
+
get selectAll() {
|
|
127
|
+
const range = this.selectionRange;
|
|
128
|
+
return Boolean(range && range[0] === 0 && range[1] === [...this.text].length);
|
|
129
|
+
}
|
|
130
|
+
set selectAll(value) {
|
|
131
|
+
this.selectionAnchor = value ? 0 : null;
|
|
132
|
+
if (value) this.cursor = [...this.text].length;
|
|
133
|
+
}
|
|
134
|
+
moveCursor(position, extend = false) {
|
|
135
|
+
if (extend) this.selectionAnchor ??= this.cursor;
|
|
136
|
+
else this._deselect();
|
|
137
|
+
this.cursor = Math.max(0, Math.min([...this.text].length, position));
|
|
138
|
+
this.render();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
// Update current URL and re-render (if not editing)
|
|
143
|
+
setUrl(url) {
|
|
144
|
+
this.currentUrl = url;
|
|
145
|
+
this._dirty = true;
|
|
146
|
+
if (!this.editing) this.render();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Status message (downloads, etc.)
|
|
150
|
+
setStatus(msg) { this._status = msg; this._dirty = true; }
|
|
151
|
+
clearStatus() { this._status = null; this._dirty = true; }
|
|
152
|
+
|
|
153
|
+
// Render only if content changed (for frame callback)
|
|
154
|
+
renderIfDirty() {
|
|
155
|
+
if (this._dirty) {
|
|
156
|
+
this.render();
|
|
157
|
+
} else if (this.editing) {
|
|
158
|
+
// Kitty frames position the terminal cursor on row 2. Keep video live
|
|
159
|
+
// while editing, restoring only the input cursor rather than the row.
|
|
160
|
+
const view = this._editView(this.columns ?? 80);
|
|
161
|
+
const visibility = this.selectionRange ? '\x1b[?25l' : '\x1b[?25h';
|
|
162
|
+
process.stdout.write(`\x1b[1;${view.cursorCol + 1}H${visibility}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Render on line 1
|
|
167
|
+
render() {
|
|
168
|
+
this._dirty = false;
|
|
169
|
+
this._updateSpinTimer();
|
|
170
|
+
const cols = this.columns ?? 80;
|
|
171
|
+
if (!this.editing) {
|
|
172
|
+
process.stdout.write(`\x1b[1;1H${this._displayLine(cols)}\x1b[0m`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const view = this._editView(cols);
|
|
176
|
+
// Like jsmdcui, hide the block cursor during selection: it otherwise
|
|
177
|
+
// makes the exclusive right endpoint look like one more selected cell.
|
|
178
|
+
const cursorVisibility = this.selectionRange ? '\x1b[?25l' : '\x1b[?25h';
|
|
179
|
+
process.stdout.write(`\x1b[?25l\x1b[1;1H${this._editLine(cols, view)}\x1b[0m\x1b[1;${view.cursorCol + 1}H${cursorVisibility}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The edit line's horizontal scroll: long text scrolls so the cursor stays
|
|
183
|
+
// visible, and Home brings back its start (jsmdcui's prompt).
|
|
184
|
+
_editView(cols) {
|
|
185
|
+
// At the start of the text (Home, or Left there) the whole prompt shows
|
|
186
|
+
// too, instead of only scrolling as far as the cursor as jsmdcui does.
|
|
187
|
+
if (this.cursor === 0) this._scrollX = 0;
|
|
188
|
+
const before = [...this.text].slice(0, this.cursor).join('');
|
|
189
|
+
const view = promptView(this.prompt, this.text, before.length, cols, this._scrollX);
|
|
190
|
+
this._scrollX = view.scrollX;
|
|
191
|
+
return view;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Start/stop spinner timer (100ms interval instead of every frame)
|
|
195
|
+
_updateSpinTimer() {
|
|
196
|
+
if (this.loading && !this.commandEntry && !this._spinTimer) {
|
|
197
|
+
this._spinTimer = setInterval(() => { this._dirty = true; }, 100);
|
|
198
|
+
} else if ((!this.loading || this.commandEntry) && this._spinTimer) {
|
|
199
|
+
clearInterval(this._spinTimer);
|
|
200
|
+
this._spinTimer = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_displayLine(cols) {
|
|
205
|
+
if (this.commandEntry) {
|
|
206
|
+
const content = this._status || this.currentUrl;
|
|
207
|
+
const color = this.loading ? 214 : 81;
|
|
208
|
+
return `\x1b[48;5;236m\x1b[38;5;${color}m${fitWidth(' @ ', cols)}\x1b[38;5;250m${padEndByWidth(content, Math.max(0, cols - 3))}`;
|
|
209
|
+
}
|
|
210
|
+
let prefix;
|
|
211
|
+
if (this.loading) {
|
|
212
|
+
prefix = ' ' + SPINNER[this._spinIdx++ % SPINNER.length] + ' ';
|
|
213
|
+
} else {
|
|
214
|
+
prefix = ' ';
|
|
215
|
+
}
|
|
216
|
+
const content = this._status || this.currentUrl;
|
|
217
|
+
const full = prefix + content;
|
|
218
|
+
return `\x1b[38;5;250m\x1b[48;5;236m${padEndByWidth(full, cols)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_editLine(cols, view = this._editView(cols)) {
|
|
222
|
+
const prefix = this.prompt;
|
|
223
|
+
const visible = fitWidth((prefix + this.text).slice(view.start), cols - view.lead);
|
|
224
|
+
const shownPrefix = prefix.slice(Math.min(view.start, prefix.length));
|
|
225
|
+
const label = ' '.repeat(view.lead) + visible.slice(0, shownPrefix.length);
|
|
226
|
+
const text = visible.slice(shownPrefix.length);
|
|
227
|
+
const pad = ' '.repeat(Math.max(0, cols - view.lead - displayWidth(visible)));
|
|
228
|
+
const range = this.selectionRange;
|
|
229
|
+
let highlighted = text;
|
|
230
|
+
if (range) {
|
|
231
|
+
const chars = [...this.text];
|
|
232
|
+
const visibleStart = Math.max(0, view.start - prefix.length);
|
|
233
|
+
const from = Math.max(0, Math.min(text.length, chars.slice(0, range[0]).join('').length - visibleStart));
|
|
234
|
+
const to = Math.max(0, Math.min(text.length, chars.slice(0, range[1]).join('').length - visibleStart));
|
|
235
|
+
if (to > from) highlighted = text.slice(0, from) + '\x1b[7m' + text.slice(from, to) + '\x1b[27m' + text.slice(to);
|
|
236
|
+
}
|
|
237
|
+
return `\x1b[97m\x1b[48;5;24m${label}${highlighted}${pad}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Start editing. Resolves { mode: 'url', url } (url null when cancelled or
|
|
241
|
+
// no bookmark matched) or { mode: 'cmd', line }, or null when cancelled.
|
|
242
|
+
// command: true starts in "cmd> " mode with an empty line; text: start from
|
|
243
|
+
// this text (not selected, cursor at the end) instead.
|
|
244
|
+
startEditing({ command = false, text = null } = {}) {
|
|
245
|
+
this.editing = true;
|
|
246
|
+
this.mode = command ? 'cmd' : 'url';
|
|
247
|
+
this.text = text ?? (command ? '' : this.currentUrl);
|
|
248
|
+
this.cursor = [...this.text].length;
|
|
249
|
+
this.selectAll = !command && text === null;
|
|
250
|
+
this._drag = null;
|
|
251
|
+
this._lastClick = { time: 0, zone: null };
|
|
252
|
+
this._scrollX = 0;
|
|
253
|
+
this._resetHistory();
|
|
254
|
+
this.render();
|
|
255
|
+
let resolve;
|
|
256
|
+
const promise = new Promise(r => { resolve = r; });
|
|
257
|
+
this._resolve = resolve;
|
|
258
|
+
return promise;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Leave editing as if Escape was pressed
|
|
262
|
+
cancelEditing() {
|
|
263
|
+
if (this.editing) this._finishEditing(null);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// End editing mode
|
|
267
|
+
_finishEditing(result) {
|
|
268
|
+
this.editing = false;
|
|
269
|
+
this._drag = null;
|
|
270
|
+
hideCursor();
|
|
271
|
+
|
|
272
|
+
let value = null;
|
|
273
|
+
if (result) {
|
|
274
|
+
this.history.add(this.mode, result);
|
|
275
|
+
if (this.mode === 'cmd') {
|
|
276
|
+
value = { mode: 'cmd', line: result };
|
|
277
|
+
} else {
|
|
278
|
+
const url = toURL(result);
|
|
279
|
+
if (url === null) {
|
|
280
|
+
// Bookmark not found
|
|
281
|
+
this.setStatus('Bookmark not found');
|
|
282
|
+
setTimeout(() => this.clearStatus(), 2000);
|
|
283
|
+
}
|
|
284
|
+
value = { mode: 'url', url };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.render();
|
|
289
|
+
if (this._resolve) {
|
|
290
|
+
this._resolve(value);
|
|
291
|
+
this._resolve = null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Switch between the address and command prompts, keeping the text
|
|
296
|
+
toggleMode() {
|
|
297
|
+
this.mode = this.mode === 'url' ? 'cmd' : 'url';
|
|
298
|
+
this._scrollX = 0;
|
|
299
|
+
this._deselect();
|
|
300
|
+
this._resetHistory();
|
|
301
|
+
this.render();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_resetHistory() {
|
|
305
|
+
this._historyIndex = this.history.list(this.mode).length;
|
|
306
|
+
this._savedInput = '';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_setText(text) {
|
|
310
|
+
this.text = text;
|
|
311
|
+
this.cursor = [...text].length;
|
|
312
|
+
this._deselect();
|
|
313
|
+
this.render();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
historyUp() {
|
|
317
|
+
const list = this.history.list(this.mode);
|
|
318
|
+
if (this._historyIndex === list.length) this._savedInput = this.text;
|
|
319
|
+
if (this._historyIndex > 0) this._setText(list[--this._historyIndex]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
historyDown() {
|
|
323
|
+
const list = this.history.list(this.mode);
|
|
324
|
+
if (this._historyIndex < list.length) {
|
|
325
|
+
this._historyIndex++;
|
|
326
|
+
this._setText(this._historyIndex === list.length ? this._savedInput : list[this._historyIndex]);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Left press on line 1 (col is 1-based), as in jsmdcui:
|
|
331
|
+
// - double click: left third = next history entry, middle = previous, right = Enter
|
|
332
|
+
// - click on the prompt (" > " / "cmd> "): switch modes, keeping the text
|
|
333
|
+
// - elsewhere: move the cursor
|
|
334
|
+
handleClick(col, now = Date.now()) {
|
|
335
|
+
if (!this.editing) return;
|
|
336
|
+
const cols = this.columns ?? 80;
|
|
337
|
+
const third = Math.floor(cols / 3);
|
|
338
|
+
const zone = col <= third ? 'left' : col <= third * 2 ? 'middle' : 'right';
|
|
339
|
+
const double = this._lastClick.zone === zone && now - this._lastClick.time < DOUBLE_CLICK_MS;
|
|
340
|
+
this._lastClick = double ? { time: 0, zone: null } : { time: now, zone };
|
|
341
|
+
if (double) {
|
|
342
|
+
if (zone === 'left') this.historyDown();
|
|
343
|
+
else if (zone === 'middle') this.historyUp();
|
|
344
|
+
else this._finishEditing(this.text.trim() || null);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// Map the column through the scrolled edit line, as jsmdcui does; the
|
|
348
|
+
// prompt (" > " / "cmd> ") switches modes, the text moves the cursor.
|
|
349
|
+
const prefix = this.prompt;
|
|
350
|
+
const total = prefix + this.text;
|
|
351
|
+
const { start, lead } = this._editView(cols);
|
|
352
|
+
const index = visualColToCharIdx(total, start, Math.max(0, col - 1 - lead));
|
|
353
|
+
if (index < prefix.length) {
|
|
354
|
+
this.toggleMode();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.cursor = [...total.slice(prefix.length, index)].length;
|
|
358
|
+
this._deselect();
|
|
359
|
+
this.render();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
get dragging() { return Boolean(this._drag); }
|
|
363
|
+
armDrag(col) {
|
|
364
|
+
const index = this._pointerIndex(col);
|
|
365
|
+
if (index !== null) this._drag = { anchor: index, col, now: Date.now(), moved: false, opening: true };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Map the scrolled terminal cell to a Unicode code-point boundary.
|
|
369
|
+
_pointerIndex(col) {
|
|
370
|
+
const total = this.prompt + this.text;
|
|
371
|
+
const { start, lead } = this._editView(this.columns ?? 80);
|
|
372
|
+
const index = visualColToCharIdx(total, start, Math.max(0, col - 1 - lead));
|
|
373
|
+
return index < this.prompt.length ? null : [...total.slice(this.prompt.length, index)].length;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
pointerDown(col, now = Date.now()) {
|
|
377
|
+
const index = this._pointerIndex(col);
|
|
378
|
+
if (index === null) { this.handleClick(col, now); return; }
|
|
379
|
+
// Delay double-click actions until release so a drag cannot submit a URL
|
|
380
|
+
// or change history before we know whether it was a click.
|
|
381
|
+
this._drag = { anchor: index, col, now, moved: false };
|
|
382
|
+
this.moveCursor(index);
|
|
383
|
+
}
|
|
384
|
+
pointerMove(col) {
|
|
385
|
+
if (!this._drag) return;
|
|
386
|
+
const index = this._pointerIndex(col) ?? 0;
|
|
387
|
+
if (index !== this._drag.anchor || col !== this._drag.col) this._drag.moved = true;
|
|
388
|
+
this.selectionAnchor = this._drag.anchor;
|
|
389
|
+
this.cursor = index;
|
|
390
|
+
this.render();
|
|
391
|
+
}
|
|
392
|
+
pointerUp() {
|
|
393
|
+
const drag = this._drag; this._drag = null;
|
|
394
|
+
if (!drag) return;
|
|
395
|
+
if (drag.moved) this._lastClick = { time: 0, zone: null };
|
|
396
|
+
else if (!drag.opening) this.handleClick(drag.col, drag.now);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Clear selection, or replace only the selected range on input/delete.
|
|
400
|
+
_deselect() { this.selectionAnchor = null; }
|
|
401
|
+
_clearIfSelected() {
|
|
402
|
+
const range = this.selectionRange;
|
|
403
|
+
if (!range) { this._deselect(); return false; }
|
|
404
|
+
const chars = [...this.text];
|
|
405
|
+
chars.splice(range[0], range[1] - range[0]);
|
|
406
|
+
this.text = chars.join(''); this.cursor = range[0]; this._deselect();
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Ctrl+U / Ctrl+K: cut or restore the text on one side of the cursor.
|
|
411
|
+
// The two sides deliberately keep independent buffers, like bunmsh.
|
|
412
|
+
_toggleSavedText(side) {
|
|
413
|
+
const chars = [...this.text];
|
|
414
|
+
const savedKey = side === 'head' ? '_savedHead' : '_savedTail';
|
|
415
|
+
|
|
416
|
+
if (side === 'head' && this.cursor > 0) {
|
|
417
|
+
this[savedKey] = chars.slice(0, this.cursor).join('');
|
|
418
|
+
this.text = chars.slice(this.cursor).join('');
|
|
419
|
+
this.cursor = 0;
|
|
420
|
+
} else if (side === 'head' && this[savedKey]) {
|
|
421
|
+
const saved = this[savedKey];
|
|
422
|
+
this.text = saved + this.text;
|
|
423
|
+
this.cursor = [...saved].length;
|
|
424
|
+
this[savedKey] = '';
|
|
425
|
+
} else if (side === 'tail' && this.cursor < chars.length) {
|
|
426
|
+
this[savedKey] = chars.slice(this.cursor).join('');
|
|
427
|
+
this.text = chars.slice(0, this.cursor).join('');
|
|
428
|
+
} else if (side === 'tail' && this[savedKey]) {
|
|
429
|
+
this.text += this[savedKey];
|
|
430
|
+
this[savedKey] = '';
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
this._deselect();
|
|
434
|
+
this.render();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Insert text (for paste)
|
|
438
|
+
insertText(str) {
|
|
439
|
+
if (!this.editing) return;
|
|
440
|
+
this._clearIfSelected();
|
|
441
|
+
const chars = [...this.text];
|
|
442
|
+
const input = [...str];
|
|
443
|
+
this.text = chars.slice(0, this.cursor).join('') + str + chars.slice(this.cursor).join('');
|
|
444
|
+
this.cursor += input.length;
|
|
445
|
+
this.render();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Handle key input during editing (returns true if consumed)
|
|
449
|
+
handleInput(str) {
|
|
450
|
+
if (!this.editing) return false;
|
|
451
|
+
|
|
452
|
+
// Tab switches prompts without changing the text or cursor.
|
|
453
|
+
if (str === '\t') {
|
|
454
|
+
this.toggleMode();
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Enter → confirm
|
|
459
|
+
if (str === '\r' || str === '\n') {
|
|
460
|
+
this._finishEditing(this.text.trim() || null);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Escape → cancel
|
|
465
|
+
if (str === '\x1b') {
|
|
466
|
+
this._finishEditing(null);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Clipboard shortcuts are handled by the input layer.
|
|
471
|
+
if (str === '\x03') return true;
|
|
472
|
+
|
|
473
|
+
// Ctrl+U → toggle text before the cursor
|
|
474
|
+
if (str === '\x15') {
|
|
475
|
+
this._toggleSavedText('head');
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Ctrl+K → toggle text after the cursor
|
|
480
|
+
if (str === '\x0b') {
|
|
481
|
+
this._toggleSavedText('tail');
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Shift+arrows extend selection; vertical movement selects to the ends
|
|
486
|
+
// of this single-line editor. Kitty events use these same local forms.
|
|
487
|
+
const shift = /^\x1b\[1;([2468])([ABCDHF])$/.exec(str);
|
|
488
|
+
if (shift) {
|
|
489
|
+
const positions = { A: 0, B: [...this.text].length, C: this.cursor + 1,
|
|
490
|
+
D: this.cursor - 1, H: 0, F: [...this.text].length };
|
|
491
|
+
this.moveCursor(positions[shift[2]], true);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Up / Down → history of this mode
|
|
496
|
+
if (str === '\x1b[A' || str === '\x1bOA') {
|
|
497
|
+
this.historyUp();
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
if (str === '\x1b[B' || str === '\x1bOB') {
|
|
501
|
+
this.historyDown();
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Ctrl+A → select all
|
|
506
|
+
if (str === '\x01') {
|
|
507
|
+
this.selectAll = true;
|
|
508
|
+
this.cursor = [...this.text].length;
|
|
509
|
+
this.render();
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Ctrl+E → move to end
|
|
514
|
+
if (str === '\x05') {
|
|
515
|
+
this._deselect();
|
|
516
|
+
this.cursor = [...this.text].length;
|
|
517
|
+
this.render();
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Ctrl+W → delete selected text or the preceding word
|
|
522
|
+
if (str === '\x17') {
|
|
523
|
+
if (this._clearIfSelected()) { this.render(); return true; }
|
|
524
|
+
const chars = [...this.text];
|
|
525
|
+
const before = chars.slice(0, this.cursor).join('');
|
|
526
|
+
const after = chars.slice(this.cursor).join('');
|
|
527
|
+
const trimmed = before.replace(/\S+\s*$/, '');
|
|
528
|
+
this.text = trimmed + after;
|
|
529
|
+
this.cursor = [...trimmed].length;
|
|
530
|
+
this.render();
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Backspace
|
|
535
|
+
if (str === '\x7f' || str === '\x08') {
|
|
536
|
+
if (this.selectionRange) {
|
|
537
|
+
this._clearIfSelected();
|
|
538
|
+
} else if (this.cursor > 0) {
|
|
539
|
+
const chars = [...this.text];
|
|
540
|
+
chars.splice(this.cursor - 1, 1);
|
|
541
|
+
this.text = chars.join('');
|
|
542
|
+
this.cursor--;
|
|
543
|
+
}
|
|
544
|
+
this.render();
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Delete
|
|
549
|
+
if (str === '\x1b[3~') {
|
|
550
|
+
if (this.selectionRange) {
|
|
551
|
+
this._clearIfSelected();
|
|
552
|
+
} else if (this.cursor < [...this.text].length) {
|
|
553
|
+
const chars = [...this.text];
|
|
554
|
+
chars.splice(this.cursor, 1);
|
|
555
|
+
this.text = chars.join('');
|
|
556
|
+
}
|
|
557
|
+
this.render();
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Left arrow (Ctrl+Left too, as in jsmdcui) → deselect and move to start
|
|
562
|
+
if (LEFT_KEYS.has(str)) {
|
|
563
|
+
if (this.selectionRange) { this.cursor = this.selectionRange[0]; this._deselect(); }
|
|
564
|
+
else if (this.cursor > 0) this.cursor--;
|
|
565
|
+
this.render();
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Right arrow (Ctrl+Right too) → deselect and move to end
|
|
570
|
+
if (RIGHT_KEYS.has(str)) {
|
|
571
|
+
if (this.selectionRange) { this.cursor = this.selectionRange[1]; this._deselect(); }
|
|
572
|
+
else if (this.cursor < [...this.text].length) this.cursor++;
|
|
573
|
+
this.render();
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Home
|
|
578
|
+
if (HOME_KEYS.has(str)) {
|
|
579
|
+
this._deselect();
|
|
580
|
+
this.cursor = 0;
|
|
581
|
+
this.render();
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// End
|
|
586
|
+
if (END_KEYS.has(str)) {
|
|
587
|
+
this._deselect();
|
|
588
|
+
this.cursor = [...this.text].length;
|
|
589
|
+
this.render();
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Normal character input → replace all if selected
|
|
594
|
+
if (!str.startsWith('\x1b') && str.charCodeAt(0) >= 32) {
|
|
595
|
+
this._clearIfSelected();
|
|
596
|
+
const chars = [...this.text];
|
|
597
|
+
const input = [...str];
|
|
598
|
+
this.text = chars.slice(0, this.cursor).join('') + str + chars.slice(this.cursor).join('');
|
|
599
|
+
this.cursor += input.length;
|
|
600
|
+
this.render();
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return true; // Consume all input while editing
|
|
605
|
+
}
|
|
606
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {join,isAbsolute} from 'node:path';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {openSync,closeSync,ftruncateSync,unlinkSync,writeSync} from 'node:fs';
|
|
4
|
+
import {connectUnix} from './transport.js';
|
|
5
|
+
import {interfaces,decode,encodeRequest} from './protocol.js';
|
|
6
|
+
|
|
7
|
+
export class WaylandClient {
|
|
8
|
+
constructor(path) {
|
|
9
|
+
const display=process.env.WAYLAND_DISPLAY || 'wayland-0';
|
|
10
|
+
this.connection=connectUnix(path || (isAbsolute(display)?display:join(process.env.XDG_RUNTIME_DIR||tmpdir(),display)));
|
|
11
|
+
this.objects=new Map();this.nextId=2;this.globals=new Map();this.waiters=new Set();
|
|
12
|
+
this.objects.set(1,{iface:'wl_display',event:(name,args)=>{
|
|
13
|
+
if(name==='error')throw Error(`Wayland error ${args.join(': ')}`);
|
|
14
|
+
if(name==='delete_id')this.objects.delete(args[0]);
|
|
15
|
+
}});
|
|
16
|
+
this.connection.onData=()=>this.receive();
|
|
17
|
+
this.connection.onClose=()=>{clearInterval(this.timer);for(const reject of this.waiters)reject(Error('Wayland disconnected'));this.waiters.clear();this.onClose?.();};
|
|
18
|
+
this.timer=setInterval(()=>this.connection.poll(),4);
|
|
19
|
+
}
|
|
20
|
+
object(iface,event=()=>{}) {const id=this.nextId++;this.objects.set(id,{iface,event});return id;}
|
|
21
|
+
request(id,name,...args) {const object=this.objects.get(id);if(!object)throw Error(`Unknown object ${id}`);const packet=encodeRequest(id,object.iface,name,args);this.connection.send(packet.data,packet.fds);this.connection.flush();}
|
|
22
|
+
receive() {
|
|
23
|
+
const c=this.connection;
|
|
24
|
+
while(!c.closed&&c.input.length>=8){
|
|
25
|
+
const id=c.input.readUInt32LE(0),header=c.input.readUInt32LE(4),size=header>>>16,opcode=header&65535;
|
|
26
|
+
if(size<8||size%4)throw Error('Invalid event size');if(c.input.length<size)return;
|
|
27
|
+
const object=this.objects.get(id),spec=interfaces.get(object?.iface)?.events[opcode];
|
|
28
|
+
if(!spec)throw Error(`Unknown event ${id}:${opcode}`);
|
|
29
|
+
const args=decode(spec,c.input.subarray(0,size),c.fds);if(args===null)return;
|
|
30
|
+
c.input=c.input.subarray(size);object.event(spec.name,args);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
sync() {return new Promise((resolve,reject)=>{
|
|
34
|
+
const timer=setTimeout(()=>{this.waiters.delete(fail);reject(Error('Wayland roundtrip timeout'));},5000);
|
|
35
|
+
const fail=e=>{clearTimeout(timer);reject(e);};this.waiters.add(fail);
|
|
36
|
+
const id=this.object('wl_callback',()=>{clearTimeout(timer);this.waiters.delete(fail);resolve();});this.request(1,'sync',id);
|
|
37
|
+
});}
|
|
38
|
+
async initialize() {
|
|
39
|
+
this.registry=this.object('wl_registry',(event,[name,iface,version])=>{
|
|
40
|
+
if(event==='global')this.globals.set(iface,{name,version});
|
|
41
|
+
else for(const [key,value]of this.globals)if(value.name===name)this.globals.delete(key);
|
|
42
|
+
});this.request(1,'get_registry',this.registry);await this.sync();
|
|
43
|
+
this.compositor=this.bind('wl_compositor',4);this.shm=this.bind('wl_shm',1);
|
|
44
|
+
this.shell=this.bind('xdg_wm_base',1,(event,[serial])=>{if(event==='ping')this.request(this.shell,'pong',serial);});
|
|
45
|
+
await this.sync();return this;
|
|
46
|
+
}
|
|
47
|
+
bind(iface,version,event) {const global=this.globals.get(iface);if(!global)throw Error(`Compositor lacks ${iface}`);const id=this.object(iface,event);this.request(this.registry,'bind',global.name,iface,Math.min(version,global.version),id);return id;}
|
|
48
|
+
close(){this.connection.close();}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class WaylandWindow {
|
|
52
|
+
constructor(client,{width=800,height=600,title='Buninu Browser'}={}) {
|
|
53
|
+
this.client=client;this.width=width;this.height=height;this.buffers=[];this.configured=false;this.framePending=false;
|
|
54
|
+
const c=client;
|
|
55
|
+
this.surface=c.object('wl_surface');c.request(c.compositor,'create_surface',this.surface);
|
|
56
|
+
this.xdg=c.object('xdg_surface',(event,[serial])=>{
|
|
57
|
+
if(event!=='configure')return;c.request(this.xdg,'ack_configure',serial);this.configured=true;
|
|
58
|
+
if(this.pendingSize){[this.width,this.height]=this.pendingSize;this.pendingSize=null;}
|
|
59
|
+
this.onConfigure?.(this.width,this.height);
|
|
60
|
+
});c.request(c.shell,'get_xdg_surface',this.xdg,this.surface);
|
|
61
|
+
this.toplevel=c.object('xdg_toplevel',(event,args)=>{
|
|
62
|
+
if(event==='configure')this.pendingSize=[args[0]||this.width,args[1]||this.height];
|
|
63
|
+
if(event==='close')this.onClose?.();
|
|
64
|
+
});c.request(this.xdg,'get_toplevel',this.toplevel);c.request(this.toplevel,'set_title',title);c.request(this.toplevel,'set_app_id','org.buninu.browser');c.request(this.surface,'commit');
|
|
65
|
+
}
|
|
66
|
+
get available(){return this.configured&&!this.framePending&&this.buffers.filter(b=>b.busy).length<2;}
|
|
67
|
+
present(bytes,width,height) {
|
|
68
|
+
if(!this.available||width!==this.width||height!==this.height)return false;
|
|
69
|
+
const c=this.client,size=width*height*4,parts=Array.isArray(bytes)?bytes:[bytes];if(parts.reduce((sum,part)=>sum+part.length,0)!==size)throw Error('Invalid raw pixel length');
|
|
70
|
+
for(const b of [...this.buffers])if(!b.busy&&(b.width!==width||b.height!==height))this.disposeBuffer(b);
|
|
71
|
+
let b=this.buffers.find(b=>!b.busy);
|
|
72
|
+
if(!b){
|
|
73
|
+
const path=join(process.env.XDG_RUNTIME_DIR||tmpdir(),`buninu-shm-${process.pid}-${crypto.randomUUID()}`);
|
|
74
|
+
const fd=openSync(path,'wx+',0o600);unlinkSync(path);
|
|
75
|
+
try{ftruncateSync(fd,size);
|
|
76
|
+
b={fd,width,height,busy:false};const pool=c.object('wl_shm_pool');c.request(c.shm,'create_pool',pool,fd,size);
|
|
77
|
+
b.id=c.object('wl_buffer',(event)=>{if(event==='release'){b.busy=false;this.onReady?.();}});
|
|
78
|
+
c.request(pool,'create_buffer',b.id,0,width,height,width*4,1);c.request(pool,'destroy');this.buffers.push(b);
|
|
79
|
+
}catch(e){closeSync(fd);throw e;}
|
|
80
|
+
}
|
|
81
|
+
let position=0;for(const part of parts){let written=0;while(written<part.length){const n=writeSync(b.fd,part,written,part.length-written,position);if(!n)throw Error('Short SHM write');written+=n;position+=n;}}
|
|
82
|
+
b.busy=true;this.framePending=true;
|
|
83
|
+
const callback=c.object('wl_callback',()=>{this.framePending=false;this.onReady?.();});
|
|
84
|
+
c.request(this.surface,'attach',b.id,0,0);c.request(this.surface,'damage',0,0,width,height);c.request(this.surface,'frame',callback);c.request(this.surface,'commit');return true;
|
|
85
|
+
}
|
|
86
|
+
disposeBuffer(b){this.client.request(b.id,'destroy');closeSync(b.fd);this.buffers.splice(this.buffers.indexOf(b),1);}
|
|
87
|
+
close(){if(this.closed)return;this.closed=true;for(const b of [...this.buffers])this.disposeBuffer(b);this.client.request(this.toplevel,'destroy');this.client.request(this.xdg,'destroy');this.client.request(this.surface,'destroy');}
|
|
88
|
+
}
|