buninu-browser 0.3.0 → 0.5.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 +40 -4
- package/frontends/wayland/LICENSE-land +21 -0
- package/frontends/wayland/README.md +152 -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 +130 -0
- package/frontends/wayland/input.js +75 -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 +162 -0
- package/index.js +28 -1
- package/lib/audio/shared-connection.js +31 -0
- package/lib/cdp-server.js +15 -2
- 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 +54 -12
- package/lib/layout/text-layout.js +17 -9
- package/lib/paint/display-list.js +6 -1
- package/lib/qml/README.md +1427 -0
- package/lib/qml/builtins.js +771 -0
- package/lib/qml/canvas-dom.js +115 -0
- package/lib/qml/canvas.js +76 -0
- package/lib/qml/document.js +1199 -0
- package/lib/qml/fonts.js +69 -0
- package/lib/qml/format.js +9 -0
- package/lib/qml/host.js +352 -0
- package/lib/qml/images.js +199 -0
- package/lib/qml/launcher.js +132 -0
- package/lib/qml/loader.js +521 -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 +182 -0
- package/lib/qml/runtime.js +3603 -0
- package/lib/qml/shader/fragment.js +83 -0
- package/lib/qml/shader/qsb.js +27 -0
- package/lib/qml/shader/renderer.js +39 -0
- package/lib/qml/shader/runtime.js +61 -0
- package/lib/qml/shapes.js +251 -0
- package/lib/qml/shortcuts.js +65 -0
- package/lib/qml/text.js +109 -0
- package/lib/qml/textures.js +126 -0
- package/lib/qml/wayland-clipboard.js +32 -0
- package/lib/qml/window-constraints.js +16 -0
- package/lib/qml/worker-thread.js +70 -0
- package/lib/render-tree/index.js +16 -1
- package/lib/renderer/bindings.js +16 -1
- package/lib/renderer/canvas-2d.js +29 -0
- package/lib/renderer/document-selection.js +19 -0
- package/lib/renderer/host.js +45 -17
- package/lib/renderer/main.js +21 -6
- package/lib/renderer/page-realm.js +20 -10
- package/lib/renderer/page-renderer.js +497 -42
- package/lib/renderer/pointer-move.js +5 -3
- package/lib/renderer/render-interval.js +9 -0
- package/lib/renderer/text-input-paint.js +54 -0
- package/lib/renderer/text-input.js +57 -0
- package/lib/style/computed-style.js +10 -4
- package/package.json +1 -1
- package/test/audio-connection-lifetime.test.js +46 -0
- package/test/canvas-paint.test.js +8 -0
- package/test/clipboard.test.js +19 -0
- package/test/computed-style.test.js +12 -0
- package/test/document-selection.test.js +27 -0
- package/test/double-click.test.js +85 -0
- package/test/fixtures/canvas-damage.js +40 -0
- package/test/fixtures/fake-renderer.js +1 -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/omastorm-helpers/Dockerfile +35 -0
- package/test/fixtures/omastorm-helpers/check.mjs +56 -0
- package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
- package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
- package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
- package/test/fixtures/pointer-paint-batching.js +23 -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-fonts/main.qml +10 -0
- package/test/fixtures/qml-locale/check.mjs +6 -0
- package/test/fixtures/qml-locale/main.qml +8 -0
- package/test/fixtures/qml-media-audio.js +39 -0
- package/test/fixtures/qml-shader/grab.qml +14 -0
- package/test/fixtures/qml-shader/gradient.frag +5 -0
- package/test/fixtures/qml-shader/main.qml +14 -0
- package/test/fixtures/qml-shader/texture-grab.qml +25 -0
- package/test/fixtures/qml-shader/texture-size.frag +5 -0
- package/test/fixtures/qml-shader-check.mjs +24 -0
- package/test/fixtures/title-no-paint.js +20 -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-double-click.html +21 -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-mouse-buttons.html +18 -0
- package/test/manual-omastorm-engine.mjs +19 -0
- package/test/manual-omastorm-shaders.mjs +28 -0
- package/test/manual-omastorm-software.mjs +25 -0
- package/test/manual-pointer-scroll.html +43 -0
- package/test/manual-qml-removal.js +46 -0
- package/test/manual-without-qml.js +45 -0
- package/test/pointer-drag.test.js +104 -0
- package/test/qml-canvas-dom.test.js +37 -0
- package/test/qml-canvas.test.js +21 -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 +95 -0
- package/test/qml-controls-window.test.js +182 -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-fonts.test.js +31 -0
- package/test/qml-grid-multiline.test.js +48 -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-locale.test.js +45 -0
- package/test/qml-media.test.js +63 -0
- package/test/qml-nonvisual-visible.test.js +19 -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-shader-runtime.test.js +6 -0
- package/test/qml-shortcuts.test.js +39 -0
- package/test/qml-socket.test.js +60 -0
- package/test/qml-software-shader.test.js +55 -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-window-update.test.js +36 -0
- package/test/qml-worker.test.js +69 -0
- package/test/qml.test.js +83 -0
- package/test/render-interval.test.js +33 -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/title-no-paint.test.js +6 -0
- package/test/wayland-frontend.test.js +83 -0
- package/test/wayland-scroll.test.js +40 -0
- package/test/wayland-urlbar.test.js +61 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Vimium-style hint mode
|
|
2
|
+
// Alt+F or Ctrl+L shows labels on clickable/focusable elements, select by typing the label
|
|
3
|
+
|
|
4
|
+
import { dispatchClick } from './click.js';
|
|
5
|
+
|
|
6
|
+
const HINT_CHARS = ['a', 's', 'd', 'f', 'j', 'k', 'l'];
|
|
7
|
+
const MAX_HINTS = HINT_CHARS.length * HINT_CHARS.length; // 49
|
|
8
|
+
|
|
9
|
+
// Collect elements + generate labels + inject overlay in a single evaluate call
|
|
10
|
+
function makeCollectAndOverlayScript(hintChars, maxHints) {
|
|
11
|
+
return `(() => {
|
|
12
|
+
try {
|
|
13
|
+
const old = document.getElementById('__casty_hints');
|
|
14
|
+
if (old) old.remove();
|
|
15
|
+
for (const el of document.querySelectorAll('[data-casty-hint-id]')) {
|
|
16
|
+
el.removeAttribute('data-casty-hint-id');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CLICKABLE = 'a, button, [role="button"], [onclick], summary, [role="link"], [role="tab"], [tabindex]';
|
|
20
|
+
const FOCUSABLE = 'input, textarea, select, [contenteditable]';
|
|
21
|
+
const all = document.querySelectorAll(CLICKABLE + ', ' + FOCUSABLE);
|
|
22
|
+
|
|
23
|
+
const elems = [];
|
|
24
|
+
for (const el of all) {
|
|
25
|
+
if (elems.length >= ${maxHints}) break;
|
|
26
|
+
const style = getComputedStyle(el);
|
|
27
|
+
if (style.display === 'none' || style.visibility === 'hidden'
|
|
28
|
+
|| style.pointerEvents === 'none' || Number(style.opacity) === 0) continue;
|
|
29
|
+
|
|
30
|
+
// getClientRects handles wrapped links better than one large bounding
|
|
31
|
+
// box whose centre may be whitespace or belong to another element.
|
|
32
|
+
const rects = [...el.getClientRects()];
|
|
33
|
+
if (!rects.length && el.firstElementChild) rects.push(...el.firstElementChild.getClientRects());
|
|
34
|
+
let point = null;
|
|
35
|
+
for (const rect of rects) {
|
|
36
|
+
const left = Math.max(0, rect.left);
|
|
37
|
+
const top = Math.max(0, rect.top);
|
|
38
|
+
const right = Math.min(innerWidth, rect.right);
|
|
39
|
+
const bottom = Math.min(innerHeight, rect.bottom);
|
|
40
|
+
if (right <= left || bottom <= top) continue;
|
|
41
|
+
const cx = Math.round((left + right) / 2);
|
|
42
|
+
const cy = Math.round((top + bottom) / 2);
|
|
43
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
44
|
+
if (hit === el || el.contains(hit)) {
|
|
45
|
+
point = { x: Math.round(left), y: Math.round(top), cx, cy };
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!point) continue;
|
|
50
|
+
|
|
51
|
+
const isFocusable = el.matches('input, textarea, select, [contenteditable]');
|
|
52
|
+
const id = String(elems.length);
|
|
53
|
+
el.setAttribute('data-casty-hint-id', id);
|
|
54
|
+
elems.push({
|
|
55
|
+
id,
|
|
56
|
+
...point,
|
|
57
|
+
type: isFocusable ? 'focus' : 'click',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (elems.length === 0) return JSON.stringify([]);
|
|
62
|
+
|
|
63
|
+
// Generate labels
|
|
64
|
+
const chars = ${JSON.stringify(hintChars)};
|
|
65
|
+
const labels = [];
|
|
66
|
+
if (elems.length <= chars.length) {
|
|
67
|
+
for (let i = 0; i < elems.length; i++) labels.push(chars[i]);
|
|
68
|
+
} else {
|
|
69
|
+
for (const c1 of chars) {
|
|
70
|
+
for (const c2 of chars) {
|
|
71
|
+
labels.push(c1 + c2);
|
|
72
|
+
if (labels.length >= elems.length) break;
|
|
73
|
+
}
|
|
74
|
+
if (labels.length >= elems.length) break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Inject overlay
|
|
79
|
+
const container = document.createElement('div');
|
|
80
|
+
container.id = '__casty_hints';
|
|
81
|
+
container.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none;';
|
|
82
|
+
for (let i = 0; i < elems.length; i++) {
|
|
83
|
+
const h = elems[i];
|
|
84
|
+
h.label = labels[i];
|
|
85
|
+
const span = document.createElement('span');
|
|
86
|
+
span.textContent = labels[i].toUpperCase();
|
|
87
|
+
span.dataset.label = labels[i];
|
|
88
|
+
span.style.cssText = 'position:fixed;background:#FFEE00;color:#000;font:bold 12px monospace;border:1px solid #C38A00;border-radius:3px;padding:0 2px;z-index:2147483647;pointer-events:none;line-height:1.4;'
|
|
89
|
+
+ 'left:' + h.x + 'px;top:' + h.y + 'px;';
|
|
90
|
+
container.appendChild(span);
|
|
91
|
+
}
|
|
92
|
+
document.documentElement.appendChild(container);
|
|
93
|
+
|
|
94
|
+
return JSON.stringify(elems);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return JSON.stringify([]);
|
|
97
|
+
}
|
|
98
|
+
})()`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const COLLECT_OVERLAY_SCRIPT = makeCollectAndOverlayScript(HINT_CHARS, MAX_HINTS);
|
|
102
|
+
|
|
103
|
+
// Dim non-matching labels
|
|
104
|
+
function makeDimScript(buffer) {
|
|
105
|
+
return `(() => {
|
|
106
|
+
const c = document.getElementById('__casty_hints');
|
|
107
|
+
if (!c) return;
|
|
108
|
+
for (const span of c.children) {
|
|
109
|
+
const label = span.dataset.label;
|
|
110
|
+
span.style.opacity = label.startsWith('${buffer}') ? '1' : '0.2';
|
|
111
|
+
}
|
|
112
|
+
})()`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Remove overlay script
|
|
116
|
+
const REMOVE_OVERLAY_SCRIPT = `(() => {
|
|
117
|
+
const el = document.getElementById('__casty_hints');
|
|
118
|
+
if (el) el.remove();
|
|
119
|
+
})()`;
|
|
120
|
+
|
|
121
|
+
const CLEANUP_HINTS_SCRIPT = `(() => {
|
|
122
|
+
const overlay = document.getElementById('__casty_hints');
|
|
123
|
+
if (overlay) overlay.remove();
|
|
124
|
+
for (const el of document.querySelectorAll('[data-casty-hint-id]')) {
|
|
125
|
+
el.removeAttribute('data-casty-hint-id');
|
|
126
|
+
}
|
|
127
|
+
})()`;
|
|
128
|
+
|
|
129
|
+
// Re-resolve the selected element after the overlay is gone. Wait for a
|
|
130
|
+
// stable, unobscured point so animations and late layout do not leave us
|
|
131
|
+
// clicking the coordinates captured when hint mode began.
|
|
132
|
+
function makeResolveHintScript(id, focus) {
|
|
133
|
+
return `(async () => {
|
|
134
|
+
const deadline = performance.now() + 2000;
|
|
135
|
+
let previous = null;
|
|
136
|
+
for (;;) {
|
|
137
|
+
const el = document.querySelector('[data-casty-hint-id="${id}"]');
|
|
138
|
+
if (!el) throw new Error('hint target disappeared');
|
|
139
|
+
const style = getComputedStyle(el);
|
|
140
|
+
if (style.display !== 'none' && style.visibility !== 'hidden'
|
|
141
|
+
&& style.pointerEvents !== 'none' && Number(style.opacity) !== 0) {
|
|
142
|
+
const rects = [...el.getClientRects()];
|
|
143
|
+
if (!rects.length && el.firstElementChild) rects.push(...el.firstElementChild.getClientRects());
|
|
144
|
+
for (const rect of rects) {
|
|
145
|
+
const left = Math.max(0, rect.left), top = Math.max(0, rect.top);
|
|
146
|
+
const right = Math.min(innerWidth, rect.right), bottom = Math.min(innerHeight, rect.bottom);
|
|
147
|
+
if (right <= left || bottom <= top) continue;
|
|
148
|
+
const x = (left + right) / 2, y = (top + bottom) / 2;
|
|
149
|
+
const hit = document.elementFromPoint(x, y);
|
|
150
|
+
const stable = previous && previous.x === x && previous.y === y;
|
|
151
|
+
previous = { x, y };
|
|
152
|
+
if (stable && (hit === el || el.contains(hit))) {
|
|
153
|
+
${focus ? 'el.focus();' : ''}
|
|
154
|
+
const anchor = el.closest('a[target="_blank"]');
|
|
155
|
+
if (anchor) anchor.target = '_self';
|
|
156
|
+
const form = el.closest('form[target="_blank"]');
|
|
157
|
+
if (form) form.target = '_self';
|
|
158
|
+
// Layout coordinates to input (visual viewport) coordinates:
|
|
159
|
+
// they differ when the page is zoomed (pinch zoom, or buninu-browser --mobile).
|
|
160
|
+
const viewport = window.visualViewport;
|
|
161
|
+
const scale = viewport?.scale || 1;
|
|
162
|
+
return { x: (x - (viewport?.offsetLeft || 0)) * scale, y: (y - (viewport?.offsetTop || 0)) * scale };
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (performance.now() >= deadline) throw new Error('hint target is not actionable');
|
|
168
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
169
|
+
}
|
|
170
|
+
})()`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class HintMode {
|
|
174
|
+
constructor(forceCapture) {
|
|
175
|
+
this.active = false;
|
|
176
|
+
this.buffer = '';
|
|
177
|
+
this._resolve = null;
|
|
178
|
+
this._client = null;
|
|
179
|
+
this._hints = [];
|
|
180
|
+
this._rawForceCapture = forceCapture || (() => {});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Ensure frame capture after DOM changes (immediate + delayed retry)
|
|
184
|
+
_forceCapture() {
|
|
185
|
+
this._rawForceCapture();
|
|
186
|
+
setTimeout(() => this._rawForceCapture(), 200);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Start hint mode — waits until user selects or cancels
|
|
190
|
+
async start(client) {
|
|
191
|
+
this._client = client;
|
|
192
|
+
this.buffer = '';
|
|
193
|
+
|
|
194
|
+
// Single evaluate: collect elements + generate labels + inject overlay
|
|
195
|
+
let hints;
|
|
196
|
+
try {
|
|
197
|
+
const { result } = await client.send('Runtime.evaluate', {
|
|
198
|
+
expression: COLLECT_OVERLAY_SCRIPT,
|
|
199
|
+
});
|
|
200
|
+
hints = JSON.parse(result.value);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
console.error(`casty: hints failed: ${e.message}`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!hints || hints.length === 0) {
|
|
207
|
+
console.error('casty: hints: no elements found');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.error(`casty: hints: ${hints.length} elements`);
|
|
212
|
+
this._hints = hints;
|
|
213
|
+
this.active = true;
|
|
214
|
+
this._forceCapture();
|
|
215
|
+
|
|
216
|
+
let resolve;
|
|
217
|
+
const promise = new Promise(r => { resolve = r; });
|
|
218
|
+
this._resolve = resolve;
|
|
219
|
+
return promise;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Handle input (only called while active)
|
|
223
|
+
async handleInput(str) {
|
|
224
|
+
if (!this.active) return false;
|
|
225
|
+
|
|
226
|
+
// Escape → cancel
|
|
227
|
+
if (str === '\x1b') {
|
|
228
|
+
await this._cancel();
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Backspace → remove last buffer char
|
|
233
|
+
if (str === '\x7f' || str === '\x08') {
|
|
234
|
+
if (this.buffer.length > 0) {
|
|
235
|
+
this.buffer = this.buffer.slice(0, -1);
|
|
236
|
+
await this._updateDim();
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Ignore non-hint characters
|
|
242
|
+
const ch = str.toLowerCase();
|
|
243
|
+
if (ch.length !== 1 || !HINT_CHARS.includes(ch)) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.buffer += ch;
|
|
248
|
+
|
|
249
|
+
// Exact match check
|
|
250
|
+
const exact = this._hints.find(h => h.label === this.buffer);
|
|
251
|
+
if (exact) {
|
|
252
|
+
await this._selectHint(exact);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Prefix match check
|
|
257
|
+
const partial = this._hints.some(h => h.label.startsWith(this.buffer));
|
|
258
|
+
if (!partial) {
|
|
259
|
+
this.buffer = '';
|
|
260
|
+
await this._updateDim();
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Partial match — dim non-matching labels
|
|
265
|
+
await this._updateDim();
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async _updateDim() {
|
|
270
|
+
try {
|
|
271
|
+
await this._client.send('Runtime.evaluate', {
|
|
272
|
+
expression: this.buffer ? makeDimScript(this.buffer) : makeDimScript(''),
|
|
273
|
+
});
|
|
274
|
+
this._forceCapture();
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async _selectHint(hint) {
|
|
279
|
+
await this._removeOverlay();
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const { result, exceptionDetails } = await this._client.send('Runtime.evaluate', {
|
|
283
|
+
expression: makeResolveHintScript(hint.id, hint.type === 'focus'),
|
|
284
|
+
awaitPromise: true,
|
|
285
|
+
returnByValue: true,
|
|
286
|
+
});
|
|
287
|
+
if (exceptionDetails) {
|
|
288
|
+
throw new Error(exceptionDetails.exception?.description
|
|
289
|
+
|| exceptionDetails.text || 'hint target evaluation failed');
|
|
290
|
+
}
|
|
291
|
+
const point = result?.value;
|
|
292
|
+
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
293
|
+
throw new Error('hint target returned no clickable point');
|
|
294
|
+
}
|
|
295
|
+
await dispatchClick(this._client, point.x, point.y, { source: `hint ${hint.label}` });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error(`casty: hint click failed: ${error.message}`);
|
|
298
|
+
} finally {
|
|
299
|
+
await this._cleanupHints();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this._done();
|
|
303
|
+
this._forceCapture();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async _cancel() {
|
|
307
|
+
await this._cleanupHints();
|
|
308
|
+
this._done();
|
|
309
|
+
this._forceCapture();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_done() {
|
|
313
|
+
this.active = false;
|
|
314
|
+
this.buffer = '';
|
|
315
|
+
this._hints = [];
|
|
316
|
+
if (this._resolve) { this._resolve(); this._resolve = null; }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async _removeOverlay() {
|
|
320
|
+
try {
|
|
321
|
+
await this._client.send('Runtime.evaluate', {
|
|
322
|
+
expression: REMOVE_OVERLAY_SCRIPT,
|
|
323
|
+
});
|
|
324
|
+
} catch {}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async _cleanupHints() {
|
|
328
|
+
try {
|
|
329
|
+
await this._client.send('Runtime.evaluate', {
|
|
330
|
+
expression: CLEANUP_HINTS_SCRIPT,
|
|
331
|
+
});
|
|
332
|
+
} catch {}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Key binding configuration
|
|
2
|
+
// Customizable via ~/.casty/keys.json
|
|
3
|
+
// Only defines actions casty intercepts; everything else passes through to Chrome.
|
|
4
|
+
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { loadJsonFile } from './config.js';
|
|
8
|
+
|
|
9
|
+
const configPath = join(homedir(), '.casty', 'keys.json');
|
|
10
|
+
|
|
11
|
+
// Default key bindings
|
|
12
|
+
// Key names: "ctrl+c", "alt+left", "f5", "ctrl+shift+r", etc.
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
'ctrl+q': 'quit',
|
|
15
|
+
'alt+left': 'back',
|
|
16
|
+
'alt+right': 'forward',
|
|
17
|
+
'ctrl+u': 'back',
|
|
18
|
+
'ctrl+k': 'forward',
|
|
19
|
+
'alt+l': 'url_bar',
|
|
20
|
+
'alt+c': 'copy',
|
|
21
|
+
'ctrl+v': 'paste',
|
|
22
|
+
'alt+f': 'hints',
|
|
23
|
+
'ctrl+l': 'hints',
|
|
24
|
+
'ctrl+e': 'command',
|
|
25
|
+
};
|
|
26
|
+
// Other actions for keys.json: reload, home, copy_url, paste_url, download, eruda, zoom_in, zoom_out, zoom_reset
|
|
27
|
+
|
|
28
|
+
// Load bindings (falls back to defaults)
|
|
29
|
+
export function loadKeyBindings() {
|
|
30
|
+
return { ...DEFAULTS, ...loadJsonFile(configPath, {}) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build a human-readable key name from parsed input
|
|
34
|
+
// { key: 'ArrowLeft', modifiers: 1 } → "alt+left"
|
|
35
|
+
// { key: 'r', modifiers: 2 } → "ctrl+r"
|
|
36
|
+
// { key: 'F5', modifiers: 0 } → "f5"
|
|
37
|
+
export function toKeyName(info) {
|
|
38
|
+
const parts = [];
|
|
39
|
+
if (info.modifiers & 2) parts.push('ctrl');
|
|
40
|
+
if (info.modifiers & 1) parts.push('alt');
|
|
41
|
+
if (info.modifiers & 8) parts.push('shift');
|
|
42
|
+
if (info.modifiers & 4) parts.push('meta');
|
|
43
|
+
|
|
44
|
+
// Normalize key name
|
|
45
|
+
const k = info.key;
|
|
46
|
+
const normalized =
|
|
47
|
+
k === 'ArrowUp' ? 'up' :
|
|
48
|
+
k === 'ArrowDown' ? 'down' :
|
|
49
|
+
k === 'ArrowLeft' ? 'left' :
|
|
50
|
+
k === 'ArrowRight' ? 'right' :
|
|
51
|
+
k === ' ' ? 'space' :
|
|
52
|
+
k.toLowerCase();
|
|
53
|
+
|
|
54
|
+
parts.push(normalized);
|
|
55
|
+
return parts.join('+');
|
|
56
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Extracted unchanged from Casty lib/input.js (MIT), except exporting the helpers.
|
|
2
|
+
export const FIND_SCRIPT = `(query) => {
|
|
3
|
+
const state = window.__castyFind || (window.__castyFind = { text: '', index: -1 });
|
|
4
|
+
if (query) { if (query !== state.text) state.index = -1; state.text = query; }
|
|
5
|
+
if (!state.text) return { error: 'Usage: find <text>' };
|
|
6
|
+
// Search the concatenated text, so a match may span nodes (pages that wrap
|
|
7
|
+
// each letter in its own element, for one)
|
|
8
|
+
const nodes = [], starts = [];
|
|
9
|
+
let all = '';
|
|
10
|
+
const walk = (node) => {
|
|
11
|
+
for (const child of node.childNodes) {
|
|
12
|
+
if (child.nodeType === 3) { nodes.push(child); starts.push(all.length); all += child.data; }
|
|
13
|
+
else if (child.nodeType === 1 && !/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/i.test(child.tagName)) walk(child);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
if (document.body) walk(document.body);
|
|
17
|
+
const haystack = all.toLowerCase(), needle = state.text.toLowerCase();
|
|
18
|
+
const matches = [];
|
|
19
|
+
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) matches.push(at);
|
|
20
|
+
if (!matches.length) return { text: state.text, total: 0, index: -1 };
|
|
21
|
+
state.index = (state.index + 1) % matches.length;
|
|
22
|
+
// Text node and offset holding character \`at\` (the last node starting at or before it)
|
|
23
|
+
const locate = (at, end) => {
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i + 1 < nodes.length && (starts[i + 1] < at || (!end && starts[i + 1] === at))) i++;
|
|
26
|
+
return [nodes[i], at - starts[i]];
|
|
27
|
+
};
|
|
28
|
+
const from = matches[state.index];
|
|
29
|
+
const [startNode, startOffset] = locate(from, false);
|
|
30
|
+
const [endNode, endOffset] = locate(from + needle.length, true);
|
|
31
|
+
try {
|
|
32
|
+
const range = document.createRange();
|
|
33
|
+
range.setStart(startNode, startOffset);
|
|
34
|
+
range.setEnd(endNode, endOffset);
|
|
35
|
+
const selection = window.getSelection();
|
|
36
|
+
selection.removeAllRanges();
|
|
37
|
+
selection.addRange(range);
|
|
38
|
+
} catch {}
|
|
39
|
+
startNode.parentElement?.scrollIntoView?.({ block: 'center' });
|
|
40
|
+
return { text: state.text, total: matches.length, index: state.index };
|
|
41
|
+
}`;
|
|
42
|
+
|
|
43
|
+
export async function findInPage(client, query) {
|
|
44
|
+
const { result, exceptionDetails } = await client.send('Runtime.evaluate', {
|
|
45
|
+
expression: `(${FIND_SCRIPT})(${JSON.stringify(query)})`,
|
|
46
|
+
returnByValue: true,
|
|
47
|
+
});
|
|
48
|
+
if (exceptionDetails) return { error: 'Find failed' };
|
|
49
|
+
return result?.value ?? { error: 'Find failed' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Ported from ../exeapk's ERUDA_TOGGLE_JS: loads eruda (github.com/liriliri/eruda)
|
|
53
|
+
// from a CDN on first use, then only shows or hides it. Resolves to a status
|
|
54
|
+
// instead of alert()ing, which would block a headless page.
|
|
55
|
+
const ERUDA_SCRIPT = `new Promise(resolve => {
|
|
56
|
+
const toggle = () => {
|
|
57
|
+
window.__erudaShown = !window.__erudaShown;
|
|
58
|
+
if (window.__erudaShown) eruda.show(); else eruda.hide();
|
|
59
|
+
resolve(window.__erudaShown ? 'Eruda shown' : 'Eruda hidden');
|
|
60
|
+
};
|
|
61
|
+
if (window.eruda) { toggle(); return; }
|
|
62
|
+
const s = document.createElement('script');
|
|
63
|
+
s.src = 'https://cdn.jsdelivr.net/npm/eruda';
|
|
64
|
+
s.onload = () => {
|
|
65
|
+
try { eruda.init(); window.__erudaShown = false; toggle(); }
|
|
66
|
+
catch (e) { resolve('Eruda failed: ' + e.message); }
|
|
67
|
+
};
|
|
68
|
+
s.onerror = () => resolve('Eruda load failed (need network, or blocked by the page)');
|
|
69
|
+
(document.body || document.documentElement).appendChild(s);
|
|
70
|
+
})`;
|
|
71
|
+
|
|
72
|
+
export async function toggleEruda(client) {
|
|
73
|
+
const { result, exceptionDetails } = await client.send('Runtime.evaluate', {
|
|
74
|
+
expression: ERUDA_SCRIPT,
|
|
75
|
+
awaitPromise: true,
|
|
76
|
+
returnByValue: true,
|
|
77
|
+
});
|
|
78
|
+
if (exceptionDetails) return 'Eruda failed';
|
|
79
|
+
return result?.value ?? 'Eruda failed';
|
|
80
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Terminal display widths for the address bar, copied from jsmdcui's command
|
|
2
|
+
// prompt (MIT, Copyright (c) 2016-2020 Zachary Yedidia, et al.; Copyright (c)
|
|
3
|
+
// 2026 Dr. John). Indices are UTF-16 code units, as in jsmdcui.
|
|
4
|
+
|
|
5
|
+
function isWideCodePoint(cp) {
|
|
6
|
+
if (cp < 0x1100) return false;
|
|
7
|
+
return (
|
|
8
|
+
cp <= 0x115F ||
|
|
9
|
+
cp === 0x2329 || cp === 0x232A ||
|
|
10
|
+
(cp >= 0x2E80 && cp <= 0x303E) ||
|
|
11
|
+
(cp >= 0x3040 && cp <= 0x33FF) ||
|
|
12
|
+
(cp >= 0x3400 && cp <= 0x4DBF) ||
|
|
13
|
+
(cp >= 0x4E00 && cp <= 0xA4C6) ||
|
|
14
|
+
(cp >= 0xA960 && cp <= 0xA97C) ||
|
|
15
|
+
(cp >= 0xAC00 && cp <= 0xD7A3) ||
|
|
16
|
+
(cp >= 0xF900 && cp <= 0xFAFF) ||
|
|
17
|
+
(cp >= 0xFE10 && cp <= 0xFE19) ||
|
|
18
|
+
(cp >= 0xFE30 && cp <= 0xFE4F) ||
|
|
19
|
+
(cp >= 0xFF01 && cp <= 0xFF60) ||
|
|
20
|
+
(cp >= 0xFFE0 && cp <= 0xFFE6) ||
|
|
21
|
+
(cp >= 0x1B000 && cp <= 0x1B0FF) ||
|
|
22
|
+
(cp >= 0x1F004 && cp <= 0x1F0CF) ||
|
|
23
|
+
(cp >= 0x1F18F && cp <= 0x1F19A) ||
|
|
24
|
+
(cp >= 0x1F200 && cp <= 0x1F2FF) ||
|
|
25
|
+
(cp >= 0x1F300 && cp <= 0x1FAFF) ||
|
|
26
|
+
(cp >= 0x20000 && cp <= 0x2FFFD) ||
|
|
27
|
+
(cp >= 0x30000 && cp <= 0x3FFFD)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isZeroWidthCodePoint(cp) {
|
|
32
|
+
return (
|
|
33
|
+
cp === 0x200D ||
|
|
34
|
+
(cp >= 0x0300 && cp <= 0x036F) ||
|
|
35
|
+
(cp >= 0x1AB0 && cp <= 0x1AFF) ||
|
|
36
|
+
(cp >= 0x1DC0 && cp <= 0x1DFF) ||
|
|
37
|
+
(cp >= 0x20D0 && cp <= 0x20FF) ||
|
|
38
|
+
(cp >= 0xFE00 && cp <= 0xFE0F) ||
|
|
39
|
+
(cp >= 0xFE20 && cp <= 0xFE2F) ||
|
|
40
|
+
(cp >= 0xE0100 && cp <= 0xE01EF)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isEmojiVariationBase(cp) {
|
|
45
|
+
return (
|
|
46
|
+
cp === 0x00A9 || cp === 0x00AE ||
|
|
47
|
+
cp === 0x203C || cp === 0x2049 ||
|
|
48
|
+
cp === 0x2122 || cp === 0x2139 ||
|
|
49
|
+
(cp >= 0x2194 && cp <= 0x21AA) ||
|
|
50
|
+
(cp >= 0x231A && cp <= 0x231B) ||
|
|
51
|
+
cp === 0x2328 || cp === 0x23CF ||
|
|
52
|
+
(cp >= 0x23E9 && cp <= 0x23F3) ||
|
|
53
|
+
(cp >= 0x23F8 && cp <= 0x23FA) ||
|
|
54
|
+
cp === 0x24C2 ||
|
|
55
|
+
(cp >= 0x25AA && cp <= 0x25AB) ||
|
|
56
|
+
cp === 0x25B6 || cp === 0x25C0 ||
|
|
57
|
+
(cp >= 0x25FB && cp <= 0x25FE) ||
|
|
58
|
+
(cp >= 0x2600 && cp <= 0x27BF) ||
|
|
59
|
+
(cp >= 0x2934 && cp <= 0x2935) ||
|
|
60
|
+
(cp >= 0x2B05 && cp <= 0x2B55) ||
|
|
61
|
+
cp === 0x3030 || cp === 0x303D ||
|
|
62
|
+
cp === 0x3297 || cp === 0x3299
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TAB_WIDTH = 4;
|
|
67
|
+
|
|
68
|
+
export function charWidth(ch) {
|
|
69
|
+
if (!ch) return 0;
|
|
70
|
+
const cp = ch.codePointAt(0);
|
|
71
|
+
if (cp === 9) return TAB_WIDTH;
|
|
72
|
+
if (cp < 32 || (cp >= 0x7f && cp < 0xa0) || isZeroWidthCodePoint(cp)) return 0;
|
|
73
|
+
if (isWideCodePoint(cp)) return 2;
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function displayUnitAt(text, idx) {
|
|
78
|
+
const cp = text.codePointAt(idx);
|
|
79
|
+
if (cp == null) return { text: '', width: 0, length: 0 };
|
|
80
|
+
let length = cp > 0xFFFF ? 2 : 1;
|
|
81
|
+
let unit = String.fromCodePoint(cp);
|
|
82
|
+
let width = charWidth(unit);
|
|
83
|
+
const nextCp = text.codePointAt(idx + length);
|
|
84
|
+
if (nextCp === 0xFE0F && isEmojiVariationBase(cp)) {
|
|
85
|
+
unit += String.fromCodePoint(nextCp);
|
|
86
|
+
length += 1;
|
|
87
|
+
width = 2;
|
|
88
|
+
}
|
|
89
|
+
return { text: unit, width, length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function displayWidth(text) {
|
|
93
|
+
let width = 0;
|
|
94
|
+
for (let i = 0; i < text.length;) {
|
|
95
|
+
const unit = displayUnitAt(text, i);
|
|
96
|
+
if (unit.length <= 0) break;
|
|
97
|
+
width += unit.width;
|
|
98
|
+
i += unit.length;
|
|
99
|
+
}
|
|
100
|
+
return width;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Convert a screen-column offset (visualCol) into a string char-unit index,
|
|
104
|
+
// starting from startIdx (code-unit index) in line and walking forward.
|
|
105
|
+
// Snaps to the start of a wide character when the click lands on its right cell.
|
|
106
|
+
export function visualColToCharIdx(line, startIdx, visualCol) {
|
|
107
|
+
let col = 0;
|
|
108
|
+
let i = startIdx;
|
|
109
|
+
while (i < line.length) {
|
|
110
|
+
if (col >= visualCol) break;
|
|
111
|
+
const cp = line.codePointAt(i);
|
|
112
|
+
const charLen = cp > 0xFFFF ? 2 : 1;
|
|
113
|
+
const w = charWidth(String.fromCodePoint(cp));
|
|
114
|
+
if (col + w > visualCol) break;
|
|
115
|
+
col += w;
|
|
116
|
+
i += charLen;
|
|
117
|
+
}
|
|
118
|
+
return Math.min(i, line.length);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* jsmdcui's prompt line: the label and value scroll horizontally so the
|
|
123
|
+
* cursor stays on screen. Returns the new scroll offset, the code-unit index
|
|
124
|
+
* the visible part starts at, and the cursor's screen column (0-based).
|
|
125
|
+
*/
|
|
126
|
+
export function promptView(label, value, cursor, cols, scrollX = 0) {
|
|
127
|
+
const total = label + value;
|
|
128
|
+
const cursorInTotal = displayWidth(label) + displayWidth(value.slice(0, cursor));
|
|
129
|
+
if (cursorInTotal > scrollX + cols - 1) scrollX = cursorInTotal - (cols - 1);
|
|
130
|
+
if (cursorInTotal < scrollX) scrollX = cursorInTotal;
|
|
131
|
+
scrollX = Math.max(0, scrollX);
|
|
132
|
+
let start = scrollX > 0 ? visualColToCharIdx(total, 0, scrollX) : 0;
|
|
133
|
+
// A wide character cut by the left edge is not drawn: its visible half
|
|
134
|
+
// becomes a blank column (lead), so the text and cursor columns agree.
|
|
135
|
+
let lead = 0;
|
|
136
|
+
const startWidth = displayWidth(total.slice(0, start));
|
|
137
|
+
if (startWidth < scrollX) {
|
|
138
|
+
const cp = total.codePointAt(start);
|
|
139
|
+
start += cp > 0xFFFF ? 2 : 1;
|
|
140
|
+
lead = startWidth + charWidth(String.fromCodePoint(cp)) - scrollX;
|
|
141
|
+
}
|
|
142
|
+
return { scrollX, start, lead, cursorCol: cursorInTotal - scrollX };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The leading part of `text` that fits in `cols` columns. */
|
|
146
|
+
export function fitWidth(text, cols) {
|
|
147
|
+
return text.slice(0, visualColToCharIdx(text, 0, cols));
|
|
148
|
+
}
|