@gjsify/adwaita-web 0.58.0 → 0.59.1

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.
@@ -0,0 +1,7 @@
1
+ import type { ClipboardHost } from '@gjsify/adwaita-core';
2
+ /**
3
+ * Runs `run` as the handler of a DOM `copy` event: `Clipboard.set_content` calls made meanwhile
4
+ * land in `data`. Returns whether any did, so the caller knows to `preventDefault()`.
5
+ */
6
+ export declare function interceptCopy(data: DataTransfer | null, run: () => void): boolean;
7
+ export declare const webClipboardHost: ClipboardHost;
@@ -0,0 +1 @@
1
+ export declare const GdkTest: () => Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { GtkAdjustment } from '@gjsify/adwaita-core';
2
2
  import type { Buffer, Gutter } from '@gjsify/gtksource-core';
3
+ import { STOP_EMISSION } from '@gjsify/gtksource-core';
3
4
  export declare class GtkSourceView extends HTMLElement {
4
5
  static get observedAttributes(): string[];
5
6
  private readonly session;
@@ -7,6 +8,7 @@ export declare class GtkSourceView extends HTMLElement {
7
8
  private unsubscribe;
8
9
  private built;
9
10
  private readonly gutter;
11
+ private readonly rightGutter;
10
12
  private readonly backdrop;
11
13
  private readonly area;
12
14
  private readonly vadj;
@@ -71,7 +73,11 @@ export declare class GtkSourceView extends HTMLElement {
71
73
  get bottomMargin(): number;
72
74
  set bottomMargin(value: number | string);
73
75
  /** GJS `connect(name, cb) → id`; the handler's first argument is this view. */
74
- connect(name: string, callback: (self: GtkSourceView, ...args: never[]) => void): number;
76
+ connect(name: string, callback: (self: GtkSourceView, ...args: never[]) => unknown): number;
77
+ /** `g_signal_connect_after`: runs once the plain handlers and the default of the emission have run. */
78
+ connect_after(name: string, callback: (self: GtkSourceView, ...args: never[]) => unknown): number;
79
+ /** `GObject.signal_stop_emission_by_name(view, …)`: the session owns the emissions. */
80
+ [STOP_EMISSION](detailedSignal: string): void;
75
81
  disconnect(id: number): void;
76
82
  /** The inner textarea, for focus and tests. */
77
83
  get textarea(): HTMLTextAreaElement;
@@ -4,9 +4,12 @@ export interface EditorParts {
4
4
  readonly area: HTMLTextAreaElement;
5
5
  readonly backdrop: HTMLElement;
6
6
  readonly gutter: HTMLElement;
7
+ /** The RIGHT gutter's element, right of the text. */
8
+ readonly rightGutter: HTMLElement;
7
9
  }
8
10
  export declare class WebEditorDriver implements EditorDriver {
9
11
  private readonly parts;
12
+ readonly gutterSides: number[];
10
13
  private host;
11
14
  private text;
12
15
  private caret;
@@ -25,6 +28,11 @@ export declare class WebEditorDriver implements EditorDriver {
25
28
  detach(): void;
26
29
  private onInput;
27
30
  private onSelection;
31
+ /**
32
+ * The second and third press of a multi-click: GTK asks `extend-selection` before it selects the word
33
+ * or line. The first press has already placed the caret, which is the location asked about.
34
+ */
35
+ private onMouseDown;
28
36
  /** `accepts-tab` is GTK's default; a textarea would move focus, so Tab inserts a tab. */
29
37
  private onKey;
30
38
  setText(text: string): void;
@@ -43,6 +51,7 @@ export declare class WebEditorDriver implements EditorDriver {
43
51
  /** One repaint per task: a burst of `paintLine` calls from one edit costs one DOM rebuild. */
44
52
  private scheduleRender;
45
53
  private hasColumns;
54
+ private hasRightColumns;
46
55
  private render;
47
56
  /** The lines in view, or all of them where nothing is laid out (a detached element, a test DOM). */
48
57
  private visibleLines;
@@ -12,6 +12,7 @@ export { isIconAvailable, registerIcon } from './icon-registry.js';
12
12
  export * as Adw from './namespace/adw.js';
13
13
  export * as Gtk from './namespace/gtk.js';
14
14
  export * as Gio from './namespace/gio.js';
15
+ export * as Gdk from './namespace/gdk.js';
15
16
  export * as GLib from './namespace/glib.js';
16
17
  export * as GObject from './namespace/gobject.js';
17
18
  export * as GtkSource from './namespace/gtksource.js';
@@ -0,0 +1,7 @@
1
+ export declare const gdk: import("@gjsify/adwaita-core").GdkClipboard;
2
+ export declare const Display: {
3
+ get_default(): import("@gjsify/adwaita-core").Display;
4
+ };
5
+ export declare const Clipboard: typeof import("@gjsify/adwaita-core").Clipboard;
6
+ export declare const ContentProvider: Pick<typeof import("@gjsify/adwaita-core").ContentProvider, "new_for_value">;
7
+ export declare const ContentFormats: typeof import("@gjsify/adwaita-core").ContentFormats;
@@ -11,6 +11,7 @@ export declare const ParamFlags: {
11
11
  readonly CONSTRUCT: 4;
12
12
  readonly CONSTRUCT_ONLY: 8;
13
13
  };
14
+ export declare const Value: typeof import("@gjsify/adwaita-core").Value;
14
15
  export declare const TYPE_STRING: import("@gjsify/adwaita-core").GType;
15
16
  export declare const TYPE_BOOLEAN: import("@gjsify/adwaita-core").GType;
16
17
  export declare const TYPE_INT: import("@gjsify/adwaita-core").GType;
@@ -20,3 +20,8 @@ export declare const TextWindowType: Readonly<{
20
20
  readonly TOP: 5;
21
21
  readonly BOTTOM: 6;
22
22
  }>;
23
+ /** `Gtk.TextExtendSelection`: `WORD` 0 (a double click), `LINE` 1 (a triple click). */
24
+ export declare const TextExtendSelection: Readonly<{
25
+ readonly WORD: 0;
26
+ readonly LINE: 1;
27
+ }>;
@@ -1,5 +1,5 @@
1
1
  export { GtkAdjustment as Adjustment } from '@gjsify/adwaita-core';
2
- export { PolicyType, TextDirection, TextWindowType } from './gtk-enums.js';
2
+ export { PolicyType, TextDirection, TextExtendSelection, TextWindowType } from './gtk-enums.js';
3
3
  export { GtkAboutDialog as AboutDialog } from '../elements/gtk-about-dialog.js';
4
4
  export { GtkActionBar as ActionBar } from '../elements/gtk-action-bar.js';
5
5
  export { GtkApplicationWindow as ApplicationWindow } from '../elements/gtk-application-window.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/adwaita-web",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
4
4
  "description": "Adwaita/Libadwaita web components for browser targets",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -50,17 +50,17 @@
50
50
  "@codemirror/language": "^6.12.4",
51
51
  "@codemirror/state": "^6.7.0",
52
52
  "@codemirror/view": "^6.43.4",
53
- "@gjsify/adwaita-core": "^0.58.0",
54
- "@gjsify/adwaita-fonts": "^0.58.0",
55
- "@gjsify/adwaita-icons": "^0.58.0",
56
- "@gjsify/gtksource-core": "^0.58.0",
53
+ "@gjsify/adwaita-core": "^0.59.1",
54
+ "@gjsify/adwaita-fonts": "^0.59.1",
55
+ "@gjsify/adwaita-icons": "^0.59.1",
56
+ "@gjsify/gtksource-core": "^0.59.1",
57
57
  "@lezer/highlight": "^1.2.3"
58
58
  },
59
59
  "devDependencies": {
60
- "@gjsify/blueprint": "^0.58.0",
61
- "@gjsify/cli": "^0.58.0",
62
- "@gjsify/unit": "^0.58.0",
63
- "@gjsify/vite-plugin-blueprint": "^0.58.0",
60
+ "@gjsify/blueprint": "^0.59.1",
61
+ "@gjsify/cli": "^0.59.1",
62
+ "@gjsify/unit": "^0.59.1",
63
+ "@gjsify/vite-plugin-blueprint": "^0.59.1",
64
64
  "sass": "^1.101.0",
65
65
  "typescript": "^6.0.3"
66
66
  },
@@ -0,0 +1,47 @@
1
+ // How the web door publishes `Gdk.Clipboard` content (ADR 0096 Amendment 2).
2
+ //
3
+ // The browser has two paths and they are not interchangeable:
4
+ // - inside a DOM `copy` event, `event.clipboardData.setData` is synchronous and needs no
5
+ // permission, but only until the event returns; the handler must then `preventDefault()` so the
6
+ // browser does not replace the data with the selection. This is the path of a `copy-clipboard`
7
+ // handler that replaces the default copy.
8
+ // - anywhere else, `navigator.clipboard.writeText` is asynchronous and needs a secure context and
9
+ // a user activation (or the permission); a refusal rejects and the core logs it.
10
+
11
+ import type { ClipboardHost } from '@gjsify/adwaita-core';
12
+
13
+ let copying: { data: DataTransfer; wrote: boolean } | null = null;
14
+
15
+ /**
16
+ * Runs `run` as the handler of a DOM `copy` event: `Clipboard.set_content` calls made meanwhile
17
+ * land in `data`. Returns whether any did, so the caller knows to `preventDefault()`.
18
+ */
19
+ export function interceptCopy(data: DataTransfer | null, run: () => void): boolean {
20
+ if (!data) {
21
+ run();
22
+ return false;
23
+ }
24
+ const outer = copying;
25
+ const frame = { data, wrote: false };
26
+ copying = frame;
27
+ try {
28
+ run();
29
+ } finally {
30
+ copying = outer;
31
+ }
32
+ return frame.wrote;
33
+ }
34
+
35
+ export const webClipboardHost: ClipboardHost = {
36
+ writeText(text) {
37
+ if (copying) {
38
+ copying.data.setData('text/plain', text);
39
+ copying.wrote = true;
40
+ return;
41
+ }
42
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
43
+ return Promise.reject(new Error('navigator.clipboard is not available here'));
44
+ }
45
+ return navigator.clipboard.writeText(text);
46
+ },
47
+ };
@@ -0,0 +1,122 @@
1
+ // The Gdk clipboard door of adwaita-web, held to the shared vectors (real GJS is their oracle, in
2
+ // adwaita-core's `gdk.gjs.spec.ts`), plus what only a browser shows: the async Clipboard API for a
3
+ // write outside an event, and the `copy` event's `clipboardData` inside one. Neither needs the
4
+ // permission: `navigator.clipboard` is replaced by a recording fake, and the event carries a
5
+ // `DataTransfer` the test reads.
6
+
7
+ import {
8
+ driveGdkClipboardVectors,
9
+ GDK_CLIPBOARD_VECTORS,
10
+ type GdkClipboardNamespaces,
11
+ } from '@gjsify/adwaita-core/conformance';
12
+ import { describe, expect, it } from '@gjsify/unit';
13
+
14
+ import { GtkSourceView } from './gtksource/gtk-source-view.js';
15
+ import * as Gdk from './namespace/gdk.js';
16
+ import * as GObject from './namespace/gobject.js';
17
+
18
+ async function withClipboardApi<T>(
19
+ api: { writeText(text: string): Promise<void> },
20
+ body: () => Promise<T>,
21
+ ): Promise<T> {
22
+ const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
23
+ Object.defineProperty(navigator, 'clipboard', { value: api, configurable: true });
24
+ try {
25
+ return await body();
26
+ } finally {
27
+ if (original) Object.defineProperty(navigator, 'clipboard', original);
28
+ else delete (navigator as unknown as Record<string, unknown>).clipboard;
29
+ }
30
+ }
31
+
32
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
33
+
34
+ export const GdkTest = async () => {
35
+ const written: string[] = [];
36
+ await withClipboardApi({ writeText: async (text) => void written.push(text) }, () =>
37
+ driveGdkClipboardVectors(
38
+ {
39
+ name: 'adwaita-web',
40
+ isOracle: false,
41
+ display: true,
42
+ GObject,
43
+ Gdk,
44
+ readBack: async () => {
45
+ await tick();
46
+ return written[written.length - 1] ?? null;
47
+ },
48
+ } as unknown as GdkClipboardNamespaces & Parameters<typeof driveGdkClipboardVectors>[0],
49
+ { describe, it, expect },
50
+ GDK_CLIPBOARD_VECTORS,
51
+ ),
52
+ );
53
+
54
+ await describe('adwaita-web: Gdk clipboard door', async () => {
55
+ const mounted = () => {
56
+ const view = document.createElement('gtk-source-view') as GtkSourceView;
57
+ document.body.append(view);
58
+ return view;
59
+ };
60
+ const copyEvent = () =>
61
+ new ClipboardEvent('copy', { cancelable: true, bubbles: true, clipboardData: new DataTransfer() });
62
+ const setText = (text: string) => {
63
+ const value = new GObject.Value().init(GObject.TYPE_STRING);
64
+ value.set_string(text);
65
+ Gdk.Display.get_default().get_clipboard().set_content(Gdk.ContentProvider.new_for_value(value));
66
+ };
67
+
68
+ await it('writes into the copy event when a copy-clipboard handler replaces the default copy', () => {
69
+ const view = mounted();
70
+ view.buffer.text = 'ff 00';
71
+ view.textarea.setSelectionRange(0, 2);
72
+ view.connect_after('copy-clipboard', () => setText('0xFF'));
73
+ const event = copyEvent();
74
+ view.textarea.dispatchEvent(event);
75
+ expect(event.clipboardData?.getData('text/plain')).toBe('0xFF');
76
+ expect(event.defaultPrevented).toBe(true);
77
+ view.remove();
78
+ });
79
+
80
+ await it('leaves the browser copy alone when no handler wrote anything', () => {
81
+ const view = mounted();
82
+ const event = copyEvent();
83
+ view.textarea.dispatchEvent(event);
84
+ expect(event.defaultPrevented).toBe(false);
85
+ view.remove();
86
+ });
87
+
88
+ await it('publishes through navigator.clipboard outside a copy event', async () => {
89
+ const seen: string[] = [];
90
+ await withClipboardApi({ writeText: async (text) => void seen.push(text) }, async () => {
91
+ setText('outside');
92
+ await tick();
93
+ });
94
+ expect(seen[0]).toBe('outside');
95
+ });
96
+
97
+ await it('warns, and keeps the content, when the browser refuses the write', async () => {
98
+ const warnings: unknown[][] = [];
99
+ const original = console.warn;
100
+ console.warn = (...args: unknown[]) => void warnings.push(args);
101
+ try {
102
+ await withClipboardApi({ writeText: () => Promise.reject(new Error('NotAllowedError')) }, async () => {
103
+ setText('refused');
104
+ await tick();
105
+ });
106
+ } finally {
107
+ console.warn = original;
108
+ }
109
+ expect(warnings.length).toBe(1);
110
+ expect(Gdk.Display.get_default().get_clipboard().get_formats().to_string()).toBe(
111
+ 'gchararray text/plain;charset=utf-8 text/plain',
112
+ );
113
+ });
114
+
115
+ await it('puts get_display and get_clipboard on a registered class', () => {
116
+ const Widget = GObject.registerClass({ GTypeName: 'GdkSpecWidget' }, class extends HTMLElement {});
117
+ const widget = new (Widget as unknown as new () => { get_display(): unknown; get_clipboard(): unknown })();
118
+ expect(widget.get_display()).toBe(Gdk.Display.get_default());
119
+ expect(widget.get_clipboard()).toBe(Gdk.Display.get_default().get_clipboard());
120
+ });
121
+ });
122
+ };
@@ -18,9 +18,11 @@ import {
18
18
  type GObjectDoor,
19
19
  type RegisteredClass,
20
20
  type TemplateScope,
21
+ widgetDisplayMembers,
21
22
  } from '@gjsify/adwaita-core';
22
23
  import { propertyOf } from '@gjsify/adwaita-core/tags';
23
24
 
25
+ import { gdk } from './namespace/gdk.js';
24
26
  import { dispatchElement, listenElement } from './gobject-elements.js';
25
27
  import { buildTemplateTree, type BuiltTemplateTree } from './shared-tree-builder.js';
26
28
  import { registerTemplateClass } from './template-classes.js';
@@ -119,6 +121,11 @@ export const webDoor: GObjectDoor = {
119
121
  Object.setPrototypeOf((klass as { prototype: object }).prototype, Constructing.prototype);
120
122
 
121
123
  const proto = (klass as { prototype: Record<string, unknown> }).prototype;
124
+ // `this.get_display()` / `this.get_clipboard()`, as on a realized Gtk.Widget; a class's own wins.
125
+ for (const [name, member] of Object.entries(widgetDisplayMembers(gdk))) {
126
+ if (!(name in proto))
127
+ Object.defineProperty(proto, name, { value: member, writable: true, configurable: true });
128
+ }
122
129
  const inherited = proto.connectedCallback as (() => void) | undefined;
123
130
  Object.defineProperty(proto, 'connectedCallback', {
124
131
  configurable: true,
@@ -2,10 +2,13 @@ import { describe, expect, it } from '@gjsify/unit';
2
2
  import {
3
3
  GUTTER_PAINT_VECTORS,
4
4
  GTKSOURCE_BUFFER_VECTORS,
5
+ GTKSOURCE_STOP_VECTORS,
5
6
  GTKSOURCE_VIEW_DEFAULT_VECTORS,
6
7
  GTKSOURCE_VIEW_SURFACE_VECTORS,
7
8
  type GtkSourceViewSurfaceLike,
8
9
  type GtkSourceBufferLike,
10
+ type GtkSourceStopGestures,
11
+ type GtkSourceStopSurface,
9
12
  type GutterPaintSurface,
10
13
  type ViewLike,
11
14
  } from '@gjsify/gtksource-core/conformance';
@@ -34,9 +37,44 @@ export const GtkSourceViewTest = async () => {
34
37
 
35
38
  for (const vector of GTKSOURCE_VIEW_SURFACE_VECTORS) {
36
39
  await it(`surface: ${vector.rule}`, () => {
37
- expect(vector.observe({ Gtk, GtkSource } as unknown as GtkSourceViewSurfaceLike)).toStrictEqual(
38
- vector.shows,
39
- );
40
+ expect(
41
+ vector.observe({
42
+ Gtk,
43
+ GtkSource,
44
+ } as unknown as GtkSourceViewSurfaceLike),
45
+ ).toStrictEqual(vector.shows);
46
+ });
47
+ }
48
+
49
+ // The driver listens to the textarea only while the view is in a document.
50
+ const mounted = (view: unknown): GtkSourceView => {
51
+ const element = view as GtkSourceView;
52
+ if (!element.isConnected) document.body.append(element);
53
+ return element;
54
+ };
55
+ const gestures: GtkSourceStopGestures = {
56
+ extendSelection: (view, offset) => {
57
+ const area = mounted(view).textarea;
58
+ area.setSelectionRange(offset, offset);
59
+ const down = new MouseEvent('mousedown', {
60
+ detail: 2,
61
+ button: 0,
62
+ cancelable: true,
63
+ bubbles: true,
64
+ });
65
+ area.dispatchEvent(down);
66
+ return down.defaultPrevented ? [area.selectionStart, area.selectionEnd] : 'native';
67
+ },
68
+ copyClipboard: (view) => {
69
+ const copy = new Event('copy', { cancelable: true, bubbles: true });
70
+ mounted(view).textarea.dispatchEvent(copy);
71
+ },
72
+ };
73
+ for (const vector of GTKSOURCE_STOP_VECTORS) {
74
+ await it(`stopping an emission: ${vector.rule}`, () => {
75
+ const seen = vector.observe({ GtkSource, GObject } as unknown as GtkSourceStopSurface, gestures);
76
+ for (const view of document.body.querySelectorAll('gtk-source-view')) view.remove();
77
+ expect(seen).toStrictEqual(vector.shows);
40
78
  });
41
79
  }
42
80
 
@@ -67,11 +105,18 @@ export const GtkSourceViewTest = async () => {
67
105
  view.showLineNumbers = true;
68
106
  document.body.append(view);
69
107
  view.buffer.text = 'a\nb';
70
- const renderer = new GtkSource.GutterRendererText({ width_request: 24, margin_start: 2, margin_end: 3 });
71
- (renderer as unknown as { vfunc_query_data(lines: unknown, line: number): void }).vfunc_query_data =
72
- function (this: { text: string }, _lines, line) {
73
- this.text = `r${line}`;
74
- };
108
+ const renderer = new GtkSource.GutterRendererText({
109
+ width_request: 24,
110
+ margin_start: 2,
111
+ margin_end: 3,
112
+ });
113
+ (
114
+ renderer as unknown as {
115
+ vfunc_query_data(lines: unknown, line: number): void;
116
+ }
117
+ ).vfunc_query_data = function (this: { text: string }, _lines, line) {
118
+ this.text = `r${line}`;
119
+ };
75
120
  view.get_gutter(Gtk.TextWindowType.LEFT)!.insert(renderer, -1);
76
121
  await task();
77
122
  const columns = view.querySelectorAll<HTMLElement>('.gsv-columns > *');
@@ -90,15 +135,47 @@ export const GtkSourceViewTest = async () => {
90
135
  view.remove();
91
136
  });
92
137
 
138
+ await it('paints the RIGHT gutter as a column right of the text, apart from the left one', async () => {
139
+ const view = document.createElement('gtk-source-view') as GtkSourceView;
140
+ document.body.append(view);
141
+ view.buffer.text = 'a\nb';
142
+ const renderer = new GtkSource.GutterRendererText({ width_request: 12 });
143
+ (
144
+ renderer as unknown as {
145
+ vfunc_query_data(lines: unknown, line: number): void;
146
+ }
147
+ ).vfunc_query_data = function (this: { text: string }, _lines, line) {
148
+ this.text = `R${line}`;
149
+ };
150
+ const right = view.get_gutter(Gtk.TextWindowType.RIGHT)!;
151
+ right.insert(renderer, 0);
152
+ await task();
153
+ const element = view.querySelector<HTMLElement>('.gsv-gutter-right')!;
154
+ expect([...element.querySelectorAll('.gsv-cell')].map((cell) => cell.textContent)).toStrictEqual([
155
+ 'R0',
156
+ 'R1',
157
+ ]);
158
+ expect(view.classList.contains('has-right-columns')).toBe(true);
159
+ expect(view.classList.contains('has-columns')).toBe(false);
160
+ expect(view.lastElementChild).toBe(element);
161
+ right.remove(renderer);
162
+ await task();
163
+ expect(view.classList.contains('has-right-columns')).toBe(false);
164
+ view.remove();
165
+ });
166
+
93
167
  await it('paints markup as styled spans and drops the column with its renderer', async () => {
94
168
  const view = document.createElement('gtk-source-view') as GtkSourceView;
95
169
  document.body.append(view);
96
170
  view.buffer.text = 'a';
97
171
  const renderer = new GtkSource.GutterRendererText({ width_request: 10 });
98
- (renderer as unknown as { vfunc_query_data(lines: unknown, line: number): void }).vfunc_query_data =
99
- function (this: { markup: string }) {
100
- this.markup = '<b>x</b> &amp; y';
101
- };
172
+ (
173
+ renderer as unknown as {
174
+ vfunc_query_data(lines: unknown, line: number): void;
175
+ }
176
+ ).vfunc_query_data = function (this: { markup: string }) {
177
+ this.markup = '<b>x</b> &amp; y';
178
+ };
102
179
  const gutter = view.get_gutter(Gtk.TextWindowType.LEFT)!;
103
180
  gutter.insert(renderer, 0);
104
181
  await task();
@@ -9,15 +9,15 @@
9
9
  //
10
10
  // IN THIS SLICE: `buffer`, `auto-indent`, `indent-width` (held and read back, as on Android),
11
11
  // `show-line-numbers`, `highlight-current-line`, `monospace`, `editable`, `cursor-visible`, the four
12
- // margins, `connect`/`disconnect`, the GJS snake_case accessors, `vadjustment`/`hadjustment`,
13
- // `set_direction`, `get_first_child`/`get_next_sibling`, `get_gutter` (the LEFT gutter paints its renderers' `vfunc_query_data`; the right one is a model).
12
+ // margins, `connect`/`connect_after`/`disconnect`, `extend-selection` and `copy-clipboard` (stoppable), the GJS snake_case accessors, `vadjustment`/`hadjustment`,
13
+ // `set_direction`, `get_first_child`/`get_next_sibling`, `get_gutter` (both gutters paint their renderers' `vfunc_query_data`).
14
14
  //
15
15
  // Reference: GtkSourceView 5 gtksourceview.c, upstream GNOME/gtksourceview (properties and defaults)
16
16
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
17
17
 
18
18
  import { adwaitaColorScheme, GtkAdjustment, onAdwaitaColorSchemeChanged } from '@gjsify/adwaita-core';
19
19
  import type { Buffer, Gutter } from '@gjsify/gtksource-core';
20
- import { EditorSession, GutterSet, toBoolean, toNumber } from '@gjsify/gtksource-core';
20
+ import { EditorSession, GutterSet, STOP_EMISSION, toBoolean, toNumber } from '@gjsify/gtksource-core';
21
21
 
22
22
  import { WebEditorDriver } from './web-editor-driver.js';
23
23
 
@@ -53,6 +53,8 @@ gtk-source-view .gsv-gutter {
53
53
  }
54
54
  gtk-source-view.show-line-numbers .gsv-gutter,
55
55
  gtk-source-view.has-columns .gsv-gutter { display: block; }
56
+ gtk-source-view .gsv-gutter-right { display: none; }
57
+ gtk-source-view.has-right-columns .gsv-gutter-right { display: block; }
56
58
  gtk-source-view .gsv-columns { display: flex; }
57
59
  gtk-source-view .gsv-numbers,
58
60
  gtk-source-view .gsv-column { flex: none; box-sizing: content-box; }
@@ -138,6 +140,7 @@ export class GtkSourceView extends HTMLElement {
138
140
  private unsubscribe: (() => void) | null = null;
139
141
  private built = false;
140
142
  private readonly gutter = document.createElement('div');
143
+ private readonly rightGutter = document.createElement('div');
141
144
  private readonly backdrop = document.createElement('div');
142
145
  private readonly area = document.createElement('textarea');
143
146
  private readonly vadj = new GtkAdjustment();
@@ -148,6 +151,7 @@ export class GtkSourceView extends HTMLElement {
148
151
  constructor() {
149
152
  super();
150
153
  this.gutter.className = 'gsv-gutter';
154
+ this.rightGutter.className = 'gsv-gutter gsv-gutter-right';
151
155
  this.backdrop.className = 'gsv-backdrop';
152
156
  this.area.className = 'gsv-area';
153
157
  this.area.wrap = 'off';
@@ -159,6 +163,7 @@ export class GtkSourceView extends HTMLElement {
159
163
  area: this.area,
160
164
  backdrop: this.backdrop,
161
165
  gutter: this.gutter,
166
+ rightGutter: this.rightGutter,
162
167
  });
163
168
  this.session = new EditorSession(this.driver, undefined, adwaitaColorScheme());
164
169
  this.session.bindGutters(this.gutters);
@@ -180,7 +185,7 @@ export class GtkSourceView extends HTMLElement {
180
185
  const stage = document.createElement('div');
181
186
  stage.className = 'gsv-stage';
182
187
  stage.append(this.backdrop, this.area);
183
- this.append(this.gutter, stage);
188
+ this.append(this.gutter, stage, this.rightGutter);
184
189
  }
185
190
  this.driver.attach();
186
191
  this.unsubscribe = onAdwaitaColorSchemeChanged(() => this.session.setColorScheme(adwaitaColorScheme()));
@@ -369,10 +374,21 @@ export class GtkSourceView extends HTMLElement {
369
374
  }
370
375
 
371
376
  /** GJS `connect(name, cb) → id`; the handler's first argument is this view. */
372
- connect(name: string, callback: (self: GtkSourceView, ...args: never[]) => void): number {
377
+ connect(name: string, callback: (self: GtkSourceView, ...args: never[]) => unknown): number {
373
378
  return this.session.connect(name, ((_session: unknown, ...args: never[]) => callback(this, ...args)) as never);
374
379
  }
375
380
 
381
+ /** `g_signal_connect_after`: runs once the plain handlers and the default of the emission have run. */
382
+ connect_after(name: string, callback: (self: GtkSourceView, ...args: never[]) => unknown): number {
383
+ return this.session.connect_after(name, ((_session: unknown, ...args: never[]) =>
384
+ callback(this, ...args)) as never);
385
+ }
386
+
387
+ /** `GObject.signal_stop_emission_by_name(view, …)`: the session owns the emissions. */
388
+ [STOP_EMISSION](detailedSignal: string): void {
389
+ this.session[STOP_EMISSION](detailedSignal);
390
+ }
391
+
376
392
  disconnect(id: number): void {
377
393
  this.session.disconnect(id);
378
394
  }
@@ -16,7 +16,9 @@ import type {
16
16
  GutterColumn,
17
17
  StyledRun,
18
18
  } from '@gjsify/gtksource-core';
19
- import { digitCount, LineStore } from '@gjsify/gtksource-core';
19
+ import { digitCount, LineStore, WINDOW_LEFT, WINDOW_RIGHT } from '@gjsify/gtksource-core';
20
+
21
+ import { interceptCopy } from '../clipboard-host.js';
20
22
 
21
23
  const GUTTER_PADDING_PX = 8;
22
24
 
@@ -31,9 +33,12 @@ export interface EditorParts {
31
33
  readonly area: HTMLTextAreaElement;
32
34
  readonly backdrop: HTMLElement;
33
35
  readonly gutter: HTMLElement;
36
+ /** The RIGHT gutter's element, right of the text. */
37
+ readonly rightGutter: HTMLElement;
34
38
  }
35
39
 
36
40
  export class WebEditorDriver implements EditorDriver {
41
+ readonly gutterSides = [WINDOW_LEFT, WINDOW_RIGHT];
37
42
  private host: EditorHost | null = null;
38
43
  private text = '';
39
44
  private caret = 0;
@@ -65,17 +70,30 @@ export class WebEditorDriver implements EditorDriver {
65
70
  on(area, 'scroll', () => {
66
71
  backdrop.scrollTop = area.scrollTop;
67
72
  backdrop.scrollLeft = area.scrollLeft;
68
- (gutter.firstElementChild as HTMLElement | null)?.style.setProperty(
69
- 'transform',
70
- `translateY(${-area.scrollTop}px)`,
71
- );
73
+ for (const side of [gutter, this.parts.rightGutter]) {
74
+ (side.firstElementChild as HTMLElement | null)?.style.setProperty(
75
+ 'transform',
76
+ `translateY(${-area.scrollTop}px)`,
77
+ );
78
+ }
72
79
  // Only the lines in view are asked; a scroll brings new ones.
73
- if (this.hasColumns) this.scheduleRender('gutter');
80
+ if (this.hasColumns || this.hasRightColumns) this.scheduleRender('gutter');
74
81
  });
75
82
  on(area, 'select', () => this.onSelection());
76
83
  on(area, 'keyup', () => this.onSelection());
77
84
  on(area, 'mouseup', () => this.onSelection());
78
85
  on(area, 'keydown', (event) => this.onKey(event as KeyboardEvent));
86
+ on(area, 'mousedown', (event) => this.onMouseDown(event as MouseEvent));
87
+ on(area, 'copy', (event) => {
88
+ // A handler of `copy-clipboard` stopped the emission: the default, the copy, does not happen.
89
+ // One that put content on the Gdk clipboard meanwhile wrote it into this event
90
+ // (`clipboard-host.ts`); the browser must not replace it with the selection.
91
+ let ran = false;
92
+ const wrote = interceptCopy((event as ClipboardEvent).clipboardData, () => {
93
+ ran = this.host?.onNativeCopy() ?? false;
94
+ });
95
+ if (!ran || wrote) event.preventDefault();
96
+ });
79
97
  // Firefox and Chromium fire `selectionchange` on different targets for a textarea.
80
98
  on(area.ownerDocument, 'selectionchange', () => {
81
99
  if (area.ownerDocument.activeElement === area) this.onSelection();
@@ -122,6 +140,20 @@ export class WebEditorDriver implements EditorDriver {
122
140
  this.scheduleRender();
123
141
  }
124
142
 
143
+ /**
144
+ * The second and third press of a multi-click: GTK asks `extend-selection` before it selects the word
145
+ * or line. The first press has already placed the caret, which is the location asked about.
146
+ */
147
+ private onMouseDown(event: MouseEvent): void {
148
+ if (event.button !== 0 || (event.detail !== 2 && event.detail !== 3)) return;
149
+ const { area } = this.parts;
150
+ const range = this.host?.onNativeExtendSelection(event.detail === 2 ? 0 : 1, area.selectionStart ?? 0);
151
+ if (!range) return;
152
+ event.preventDefault();
153
+ area.setSelectionRange(range[0], range[1]);
154
+ this.onSelection();
155
+ }
156
+
125
157
  /** `accepts-tab` is GTK's default; a textarea would move focus, so Tab inserts a tab. */
126
158
  private onKey(event: KeyboardEvent): void {
127
159
  if (event.key !== 'Tab' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return;
@@ -234,6 +266,7 @@ export class WebEditorDriver implements EditorDriver {
234
266
  }
235
267
 
236
268
  private hasColumns = false;
269
+ private hasRightColumns = false;
237
270
 
238
271
  private render(): void {
239
272
  const { backdrop } = this.parts;
@@ -325,7 +358,7 @@ export class WebEditorDriver implements EditorDriver {
325
358
 
326
359
  /** The gutter: the renderers left of the line numbers, the numbers, the renderers right of them. */
327
360
  private renderGutter(): void {
328
- const { gutter, root, area } = this.parts;
361
+ const { gutter, rightGutter, root, area } = this.parts;
329
362
  const host = this.host;
330
363
  const count = this.text.split('\n').length;
331
364
  const [first, last] = this.visibleLines(count);
@@ -342,5 +375,15 @@ export class WebEditorDriver implements EditorDriver {
342
375
  wrapper.append(...elements);
343
376
  wrapper.style.transform = `translateY(${-area.scrollTop}px)`;
344
377
  gutter.replaceChildren(wrapper);
378
+
379
+ // The RIGHT gutter holds renderers only: the line numbers are a LEFT-side feature.
380
+ const right = host?.queryGutter(first, last, WINDOW_RIGHT) ?? [];
381
+ this.hasRightColumns = right.length > 0;
382
+ root.classList.toggle('has-right-columns', this.hasRightColumns);
383
+ const rightWrapper = document.createElement('div');
384
+ rightWrapper.className = 'gsv-columns';
385
+ rightWrapper.append(...right.map((column) => this.columnElement(column, first)));
386
+ rightWrapper.style.transform = `translateY(${-area.scrollTop}px)`;
387
+ rightGutter.replaceChildren(rightWrapper);
345
388
  }
346
389
  }
package/src/index.ts CHANGED
@@ -119,6 +119,8 @@ export * as Gtk from './namespace/gtk.js';
119
119
  // program here as on GJS — `./namespace/gio.ts` says what does not go in it.
120
120
  export * as Gio from './namespace/gio.js';
121
121
 
122
+ // Gdk's clipboard (`Display`, `Clipboard`, `ContentProvider`): `./namespace/gdk.ts` says what is absent.
123
+ export * as Gdk from './namespace/gdk.js';
122
124
  // GLib's timers (`idle_add`, `timeout_add`, `source_remove`): `./namespace/glib.ts` says what is absent.
123
125
  export * as GLib from './namespace/glib.js';
124
126
 
@@ -0,0 +1,17 @@
1
+ // The Gdk half of `@gjsify/adwaita-web`'s vocabulary, as far as the clipboard goes (ADR 0096
2
+ // Amendment 2): `Display.get_default().get_clipboard()`, `Clipboard.set_content` / `set` and
3
+ // `ContentProvider.new_for_value`. The behaviour lives once in `@gjsify/adwaita-core` (`gdk.ts`); this
4
+ // file hands it the browser's clipboard, `../clipboard-host.ts`.
5
+ //
6
+ // A TRUE SUBSET: everything else in Gdk is absent, and the `gi://Gdk` arm refuses it by name.
7
+
8
+ import { createGdk } from '@gjsify/adwaita-core';
9
+
10
+ import { webClipboardHost } from '../clipboard-host.js';
11
+
12
+ export const gdk = createGdk(webClipboardHost);
13
+
14
+ export const Display = gdk.Display;
15
+ export const Clipboard = gdk.Clipboard;
16
+ export const ContentProvider = gdk.ContentProvider;
17
+ export const ContentFormats = gdk.ContentFormats;
@@ -21,6 +21,7 @@ export const type_ensure = gobject.type_ensure;
21
21
  export const signal_stop_emission_by_name = gobject.signal_stop_emission_by_name;
22
22
  export const ParamSpec = gobject.ParamSpec;
23
23
  export const ParamFlags = gobject.ParamFlags;
24
+ export const Value = gobject.Value;
24
25
  export const TYPE_STRING = gobject.TYPE_STRING;
25
26
  export const TYPE_BOOLEAN = gobject.TYPE_BOOLEAN;
26
27
  export const TYPE_INT = gobject.TYPE_INT;
@@ -1,15 +1,34 @@
1
- // The plain GTK enums an application reads off `Gtk` — `Gtk.PolicyType`, `Gtk.TextDirection` and
2
- // `Gtk.TextWindowType`, as value tables. The numbers are the typelib's (GIR declaration order),
1
+ // The plain GTK enums an application reads off `Gtk` — `Gtk.PolicyType`, `Gtk.TextDirection`,
2
+ // `Gtk.TextExtendSelection` and `Gtk.TextWindowType`, as value tables. The numbers are the typelib's (GIR declaration order),
3
3
  // and the same tables the NativeScript port publishes.
4
4
  //
5
5
  // Reference: refs/gtk gtk/gtkenums.h (GtkPolicyType, GtkTextDirection), gtk/gtktextview.h (GtkTextWindowType)
6
6
  // Copyright (c) The GTK Team. LGPLv2.1+.
7
7
 
8
8
  /** `Gtk.PolicyType`: `ALWAYS` 0, `AUTOMATIC` 1, `NEVER` 2, `EXTERNAL` 3. */
9
- export const PolicyType = Object.freeze({ ALWAYS: 0, AUTOMATIC: 1, NEVER: 2, EXTERNAL: 3 } as const);
9
+ export const PolicyType = Object.freeze({
10
+ ALWAYS: 0,
11
+ AUTOMATIC: 1,
12
+ NEVER: 2,
13
+ EXTERNAL: 3,
14
+ } as const);
10
15
 
11
16
  /** `Gtk.TextDirection`: `NONE` 0, `LTR` 1, `RTL` 2. */
12
- export const TextDirection = Object.freeze({ NONE: 0, LTR: 1, RTL: 2 } as const);
17
+ export const TextDirection = Object.freeze({
18
+ NONE: 0,
19
+ LTR: 1,
20
+ RTL: 2,
21
+ } as const);
13
22
 
14
23
  /** `Gtk.TextWindowType`: `WIDGET` 1, `TEXT` 2, `LEFT` 3, `RIGHT` 4, `TOP` 5, `BOTTOM` 6. */
15
- export const TextWindowType = Object.freeze({ WIDGET: 1, TEXT: 2, LEFT: 3, RIGHT: 4, TOP: 5, BOTTOM: 6 } as const);
24
+ export const TextWindowType = Object.freeze({
25
+ WIDGET: 1,
26
+ TEXT: 2,
27
+ LEFT: 3,
28
+ RIGHT: 4,
29
+ TOP: 5,
30
+ BOTTOM: 6,
31
+ } as const);
32
+
33
+ /** `Gtk.TextExtendSelection`: `WORD` 0 (a double click), `LINE` 1 (a triple click). */
34
+ export const TextExtendSelection = Object.freeze({ WORD: 0, LINE: 1 } as const);
@@ -21,7 +21,7 @@
21
21
  // are the same write and nothing in this package had to learn a second input shape.
22
22
 
23
23
  export { GtkAdjustment as Adjustment } from '@gjsify/adwaita-core';
24
- export { PolicyType, TextDirection, TextWindowType } from './gtk-enums.js';
24
+ export { PolicyType, TextDirection, TextExtendSelection, TextWindowType } from './gtk-enums.js';
25
25
  export { GtkAboutDialog as AboutDialog } from '../elements/gtk-about-dialog.js';
26
26
  export { GtkActionBar as ActionBar } from '../elements/gtk-action-bar.js';
27
27
  export { GtkApplicationWindow as ApplicationWindow } from '../elements/gtk-application-window.js';
@@ -141,6 +141,7 @@ import { AdwFontsTest } from './adw-fonts.spec.js';
141
141
  import { AdwConstructVectorsTest } from './construct-vectors.spec.js';
142
142
  import { GObjectDoorTest } from './gobject-door.spec.js';
143
143
  import { ApplicationTest } from './application.spec.js';
144
+ import { GdkTest } from './gdk.spec.js';
144
145
  import { GLibTest } from './glib.spec.js';
145
146
  import { StyleManagerTest } from './style-manager.spec.js';
146
147
  import { GioActionsTest } from './gio.spec.js';
@@ -160,6 +161,7 @@ run({
160
161
  AdwConstructVectorsTest,
161
162
  GObjectDoorTest,
162
163
  ApplicationTest,
164
+ GdkTest,
163
165
  GLibTest,
164
166
  StyleManagerTest,
165
167
  GioActionsTest,