@gjsify/adwaita-web 0.57.0 → 0.59.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.
Files changed (38) hide show
  1. package/lib/types/clipboard-host.d.ts +7 -0
  2. package/lib/types/gdk.spec.d.ts +1 -0
  3. package/lib/types/gio.spec.d.ts +1 -0
  4. package/lib/types/gtksource/gtk-source-view.d.ts +11 -4
  5. package/lib/types/gtksource/web-editor-driver.d.ts +18 -0
  6. package/lib/types/index.d.ts +1 -0
  7. package/lib/types/namespace/adw.d.ts +1 -0
  8. package/lib/types/namespace/gdk.d.ts +7 -0
  9. package/lib/types/namespace/gio.d.ts +1 -1
  10. package/lib/types/namespace/glib.d.ts +5 -0
  11. package/lib/types/namespace/gobject.d.ts +2 -0
  12. package/lib/types/namespace/gtk-enums.d.ts +5 -0
  13. package/lib/types/namespace/gtk.d.ts +1 -1
  14. package/lib/types/namespace/gtksource.d.ts +1 -1
  15. package/lib/types/style-manager.d.ts +5 -0
  16. package/lib/types/style-manager.spec.d.ts +1 -0
  17. package/package.json +9 -9
  18. package/src/application.spec.ts +1 -1
  19. package/src/clipboard-host.ts +47 -0
  20. package/src/gdk.spec.ts +122 -0
  21. package/src/gio.spec.ts +23 -0
  22. package/src/glib.spec.ts +17 -2
  23. package/src/gobject-door.ts +7 -0
  24. package/src/gtksource/gtk-source-view.spec.ts +154 -5
  25. package/src/gtksource/gtk-source-view.ts +35 -12
  26. package/src/gtksource/web-editor-driver.ts +144 -15
  27. package/src/index.ts +2 -0
  28. package/src/namespace/adw.ts +1 -0
  29. package/src/namespace/gdk.ts +17 -0
  30. package/src/namespace/gio.ts +2 -2
  31. package/src/namespace/glib.ts +17 -2
  32. package/src/namespace/gobject.ts +2 -0
  33. package/src/namespace/gtk-enums.ts +24 -5
  34. package/src/namespace/gtk.ts +1 -1
  35. package/src/namespace/gtksource.ts +14 -3
  36. package/src/style-manager.spec.ts +36 -0
  37. package/src/style-manager.ts +27 -0
  38. package/src/test.browser.mts +6 -0
@@ -1,12 +1,19 @@
1
1
  import { describe, expect, it } from '@gjsify/unit';
2
2
  import {
3
+ GUTTER_PAINT_VECTORS,
3
4
  GTKSOURCE_BUFFER_VECTORS,
5
+ GTKSOURCE_STOP_VECTORS,
4
6
  GTKSOURCE_VIEW_DEFAULT_VECTORS,
5
7
  GTKSOURCE_VIEW_SURFACE_VECTORS,
6
8
  type GtkSourceViewSurfaceLike,
7
9
  type GtkSourceBufferLike,
10
+ type GtkSourceStopGestures,
11
+ type GtkSourceStopSurface,
12
+ type GutterPaintSurface,
13
+ type ViewLike,
8
14
  } from '@gjsify/gtksource-core/conformance';
9
15
 
16
+ import * as GObject from '../namespace/gobject.js';
10
17
  import * as Gtk from '../namespace/gtk.js';
11
18
  import * as GtkSource from '../namespace/gtksource.js';
12
19
 
@@ -30,14 +37,156 @@ export const GtkSourceViewTest = async () => {
30
37
 
31
38
  for (const vector of GTKSOURCE_VIEW_SURFACE_VECTORS) {
32
39
  await it(`surface: ${vector.rule}`, () => {
33
- expect(vector.observe({ Gtk, GtkSource } as unknown as GtkSourceViewSurfaceLike)).toStrictEqual(
34
- vector.shows,
35
- );
40
+ expect(
41
+ vector.observe({
42
+ Gtk,
43
+ GtkSource,
44
+ } as unknown as GtkSourceViewSurfaceLike),
45
+ ).toStrictEqual(vector.shows);
36
46
  });
37
47
  }
38
48
 
39
- await it('refuses get_gutter by name until slice 6', () => {
40
- expect(() => new GtkSourceView().get_gutter(Gtk.TextWindowType.LEFT)).toThrow(/get_gutter/);
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);
78
+ });
79
+ }
80
+
81
+ const task = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
82
+ for (const vector of GUTTER_PAINT_VECTORS) {
83
+ await it(`gutter paint: ${vector.rule}`, async () => {
84
+ const views: HTMLElement[] = [];
85
+ const harness = {
86
+ async present(view: ViewLike) {
87
+ document.body.append(view as unknown as HTMLElement);
88
+ views.push(view as unknown as HTMLElement);
89
+ await task();
90
+ },
91
+ settle: task,
92
+ };
93
+ try {
94
+ expect(
95
+ await vector.observe({ Gtk, GtkSource, GObject } as unknown as GutterPaintSurface, harness),
96
+ ).toStrictEqual(vector.shows);
97
+ } finally {
98
+ for (const view of views) view.remove();
99
+ }
100
+ });
101
+ }
102
+
103
+ await it("paints a renderer's text, in a column left of the line numbers when its position is negative", async () => {
104
+ const view = document.createElement('gtk-source-view') as GtkSourceView;
105
+ view.showLineNumbers = true;
106
+ document.body.append(view);
107
+ view.buffer.text = 'a\nb';
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
+ };
120
+ view.get_gutter(Gtk.TextWindowType.LEFT)!.insert(renderer, -1);
121
+ await task();
122
+ const columns = view.querySelectorAll<HTMLElement>('.gsv-columns > *');
123
+ expect([...columns].map((column) => column.className)).toStrictEqual(['gsv-column', 'gsv-numbers']);
124
+ const first = columns[0];
125
+ expect([...first.querySelectorAll('.gsv-cell')].map((cell) => cell.textContent)).toStrictEqual([
126
+ 'r0',
127
+ 'r1',
128
+ ]);
129
+ expect([first.style.minWidth, first.style.paddingLeft, first.style.paddingRight]).toStrictEqual([
130
+ '24px',
131
+ '2px',
132
+ '3px',
133
+ ]);
134
+ expect(view.classList.contains('has-columns')).toBe(true);
135
+ view.remove();
136
+ });
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
+
167
+ await it('paints markup as styled spans and drops the column with its renderer', async () => {
168
+ const view = document.createElement('gtk-source-view') as GtkSourceView;
169
+ document.body.append(view);
170
+ view.buffer.text = 'a';
171
+ const renderer = new GtkSource.GutterRendererText({ width_request: 10 });
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
+ };
179
+ const gutter = view.get_gutter(Gtk.TextWindowType.LEFT)!;
180
+ gutter.insert(renderer, 0);
181
+ await task();
182
+ const cell = view.querySelector<HTMLElement>('.gsv-cell')!;
183
+ expect(cell.textContent).toBe('x & y');
184
+ expect(cell.querySelector<HTMLElement>('span')!.style.fontWeight).toBe('bold');
185
+ gutter.remove(renderer);
186
+ await task();
187
+ expect(view.querySelector('.gsv-column')).toBe(null);
188
+ expect(view.classList.contains('has-columns')).toBe(false);
189
+ view.remove();
41
190
  });
42
191
 
43
192
  await it('writes through attributes with GTK semantics', () => {
@@ -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`. Not yet: gutters (`get_gutter` throws).
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
- import type { Buffer } from '@gjsify/gtksource-core';
20
- import { EditorSession, toBoolean, toNumber } from '@gjsify/gtksource-core';
19
+ import type { Buffer, Gutter } 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
 
@@ -44,15 +44,22 @@ gtk-source-view .gsv-gutter {
44
44
  flex: none;
45
45
  overflow: hidden;
46
46
  box-sizing: content-box;
47
- padding: var(--gsv-margin-top, 0px) var(--gsv-gutter-padding, 8px) 0;
48
- width: var(--gsv-gutter-width, 2ch);
47
+ padding: var(--gsv-margin-top, 0px) 0 0;
49
48
  text-align: end;
50
49
  line-height: var(--gsv-line-height);
51
50
  color: var(--gsv-number-fg);
52
51
  background: var(--gsv-number-bg);
53
52
  user-select: none;
54
53
  }
55
- gtk-source-view.show-line-numbers .gsv-gutter { display: block; }
54
+ gtk-source-view.show-line-numbers .gsv-gutter,
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; }
58
+ gtk-source-view .gsv-columns { display: flex; }
59
+ gtk-source-view .gsv-numbers,
60
+ gtk-source-view .gsv-column { flex: none; box-sizing: content-box; }
61
+ gtk-source-view .gsv-numbers { width: var(--gsv-gutter-width, 2ch); padding: 0 var(--gsv-gutter-padding, 8px); }
62
+ gtk-source-view .gsv-cell { height: calc(1em * var(--gsv-line-height)); white-space: pre; text-align: start; }
56
63
  gtk-source-view .gsv-number.current { color: var(--gsv-current-number-fg); background: var(--gsv-current-number-bg); }
57
64
  gtk-source-view .gsv-stage { position: relative; flex: 1 1 auto; min-width: 0; }
58
65
  gtk-source-view .gsv-backdrop,
@@ -133,15 +140,18 @@ export class GtkSourceView extends HTMLElement {
133
140
  private unsubscribe: (() => void) | null = null;
134
141
  private built = false;
135
142
  private readonly gutter = document.createElement('div');
143
+ private readonly rightGutter = document.createElement('div');
136
144
  private readonly backdrop = document.createElement('div');
137
145
  private readonly area = document.createElement('textarea');
138
146
  private readonly vadj = new GtkAdjustment();
139
147
  private readonly hadj = new GtkAdjustment();
140
148
  private direction = 1;
149
+ private readonly gutters = new GutterSet(this);
141
150
 
142
151
  constructor() {
143
152
  super();
144
153
  this.gutter.className = 'gsv-gutter';
154
+ this.rightGutter.className = 'gsv-gutter gsv-gutter-right';
145
155
  this.backdrop.className = 'gsv-backdrop';
146
156
  this.area.className = 'gsv-area';
147
157
  this.area.wrap = 'off';
@@ -153,8 +163,10 @@ export class GtkSourceView extends HTMLElement {
153
163
  area: this.area,
154
164
  backdrop: this.backdrop,
155
165
  gutter: this.gutter,
166
+ rightGutter: this.rightGutter,
156
167
  });
157
168
  this.session = new EditorSession(this.driver, undefined, adwaitaColorScheme());
169
+ this.session.bindGutters(this.gutters);
158
170
  }
159
171
 
160
172
  private syncAdjustments(): void {
@@ -173,7 +185,7 @@ export class GtkSourceView extends HTMLElement {
173
185
  const stage = document.createElement('div');
174
186
  stage.className = 'gsv-stage';
175
187
  stage.append(this.backdrop, this.area);
176
- this.append(this.gutter, stage);
188
+ this.append(this.gutter, stage, this.rightGutter);
177
189
  }
178
190
  this.driver.attach();
179
191
  this.unsubscribe = onAdwaitaColorSchemeChanged(() => this.session.setColorScheme(adwaitaColorScheme()));
@@ -328,9 +340,9 @@ export class GtkSourceView extends HTMLElement {
328
340
  return this.parentElement;
329
341
  }
330
342
 
331
- /** Gutters are slice 6 (ADR 0094). */
332
- get_gutter(_window_type: number): never {
333
- throw new Error('GtkSource.View.get_gutter is not implemented');
343
+ /** `null` for any side but LEFT and RIGHT, as in GtkSourceView (ADR 0103). */
344
+ get_gutter(window_type: number): Gutter | null {
345
+ return this.gutters.get(window_type);
334
346
  }
335
347
 
336
348
  get leftMargin(): number {
@@ -362,10 +374,21 @@ export class GtkSourceView extends HTMLElement {
362
374
  }
363
375
 
364
376
  /** GJS `connect(name, cb) → id`; the handler's first argument is this view. */
365
- connect(name: string, callback: (self: GtkSourceView, ...args: never[]) => void): number {
377
+ connect(name: string, callback: (self: GtkSourceView, ...args: never[]) => unknown): number {
366
378
  return this.session.connect(name, ((_session: unknown, ...args: never[]) => callback(this, ...args)) as never);
367
379
  }
368
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
+
369
392
  disconnect(id: number): void {
370
393
  this.session.disconnect(id);
371
394
  }
@@ -7,8 +7,18 @@
7
7
  // NO WORD WRAP, as on Android: `wrap="off"` and `white-space: pre` keep every logical line on
8
8
  // one layout line, so the backdrop and the gutter stay aligned by a fixed line height.
9
9
 
10
- import type { EditorDriver, EditorHost, EditorLayout, EditorPalette, StyledRun } from '@gjsify/gtksource-core';
11
- import { digitCount, LineStore } from '@gjsify/gtksource-core';
10
+ import type {
11
+ EditorDriver,
12
+ EditorHost,
13
+ EditorLayout,
14
+ EditorPalette,
15
+ GutterCell,
16
+ GutterColumn,
17
+ StyledRun,
18
+ } from '@gjsify/gtksource-core';
19
+ import { digitCount, LineStore, WINDOW_LEFT, WINDOW_RIGHT } from '@gjsify/gtksource-core';
20
+
21
+ import { interceptCopy } from '../clipboard-host.js';
12
22
 
13
23
  const GUTTER_PADDING_PX = 8;
14
24
 
@@ -23,16 +33,20 @@ export interface EditorParts {
23
33
  readonly area: HTMLTextAreaElement;
24
34
  readonly backdrop: HTMLElement;
25
35
  readonly gutter: HTMLElement;
36
+ /** The RIGHT gutter's element, right of the text. */
37
+ readonly rightGutter: HTMLElement;
26
38
  }
27
39
 
28
40
  export class WebEditorDriver implements EditorDriver {
41
+ readonly gutterSides = [WINDOW_LEFT, WINDOW_RIGHT];
29
42
  private host: EditorHost | null = null;
30
43
  private text = '';
31
44
  private caret = 0;
32
45
  private reported = { start: 0, end: 0 };
33
46
  private layout: EditorLayout | null = null;
34
47
  private readonly runs = new LineStore<readonly StyledRun[]>();
35
- private scheduled = false;
48
+ private scheduled: 'none' | 'gutter' | 'all' = 'none';
49
+ private numbers: HTMLElement | null = null;
36
50
  private listening: Array<() => void> = [];
37
51
  private palette: EditorPalette | null = null;
38
52
  private attached = false;
@@ -56,15 +70,30 @@ export class WebEditorDriver implements EditorDriver {
56
70
  on(area, 'scroll', () => {
57
71
  backdrop.scrollTop = area.scrollTop;
58
72
  backdrop.scrollLeft = area.scrollLeft;
59
- (gutter.firstElementChild as HTMLElement | null)?.style.setProperty(
60
- 'transform',
61
- `translateY(${-area.scrollTop}px)`,
62
- );
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
+ }
79
+ // Only the lines in view are asked; a scroll brings new ones.
80
+ if (this.hasColumns || this.hasRightColumns) this.scheduleRender('gutter');
63
81
  });
64
82
  on(area, 'select', () => this.onSelection());
65
83
  on(area, 'keyup', () => this.onSelection());
66
84
  on(area, 'mouseup', () => this.onSelection());
67
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
+ });
68
97
  // Firefox and Chromium fire `selectionchange` on different targets for a textarea.
69
98
  on(area.ownerDocument, 'selectionchange', () => {
70
99
  if (area.ownerDocument.activeElement === area) this.onSelection();
@@ -111,6 +140,20 @@ export class WebEditorDriver implements EditorDriver {
111
140
  this.scheduleRender();
112
141
  }
113
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
+
114
157
  /** `accepts-tab` is GTK's default; a textarea would move focus, so Tab inserts a tab. */
115
158
  private onKey(event: KeyboardEvent): void {
116
159
  if (event.key !== 'Tab' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return;
@@ -188,6 +231,10 @@ export class WebEditorDriver implements EditorDriver {
188
231
  style.setProperty('--gsv-current-number-bg', cssColor(palette.currentLineNumberBackground));
189
232
  }
190
233
 
234
+ invalidateGutter(): void {
235
+ this.scheduleRender('gutter');
236
+ }
237
+
191
238
  spliceLines(first: number, removed: number, inserted: number): void {
192
239
  this.runs.splice(first, removed, inserted);
193
240
  }
@@ -205,17 +252,24 @@ export class WebEditorDriver implements EditorDriver {
205
252
  // --- painting ----------------------------------------------------------------------------
206
253
 
207
254
  /** One repaint per task: a burst of `paintLine` calls from one edit costs one DOM rebuild. */
208
- private scheduleRender(): void {
209
- if (this.scheduled || !this.attached) return;
210
- this.scheduled = true;
255
+ private scheduleRender(only: 'gutter' | 'all' = 'all'): void {
256
+ if (!this.attached) return;
257
+ const pending = this.scheduled;
258
+ this.scheduled = only === 'all' || pending === 'all' ? 'all' : 'gutter';
259
+ if (pending !== 'none') return;
211
260
  queueMicrotask(() => {
212
- this.scheduled = false;
213
- this.render();
261
+ const level = this.scheduled;
262
+ this.scheduled = 'none';
263
+ if (level === 'all') this.render();
264
+ else if (level === 'gutter') this.renderGutter();
214
265
  });
215
266
  }
216
267
 
268
+ private hasColumns = false;
269
+ private hasRightColumns = false;
270
+
217
271
  private render(): void {
218
- const { backdrop, gutter, root } = this.parts;
272
+ const { backdrop } = this.parts;
219
273
  const lines = this.text.split('\n');
220
274
  const highlight = this.layout?.highlightCurrentLine === true;
221
275
  const caretLine = this.text.slice(0, this.caret).split('\n').length - 1;
@@ -252,9 +306,84 @@ export class WebEditorDriver implements EditorDriver {
252
306
  const column = document.createElement('div');
253
307
  column.className = 'gsv-numbers';
254
308
  column.append(...numbers);
255
- column.style.transform = `translateY(${-this.parts.area.scrollTop}px)`;
256
- gutter.replaceChildren(column);
309
+ this.numbers = column;
310
+ const { root } = this.parts;
257
311
  root.style.setProperty('--gsv-gutter-width', `${digitCount(lines.length) + 0}ch`);
258
312
  root.style.setProperty('--gsv-gutter-padding', `${GUTTER_PADDING_PX}px`);
313
+ this.renderGutter();
314
+ }
315
+
316
+ /** The lines in view, or all of them where nothing is laid out (a detached element, a test DOM). */
317
+ private visibleLines(count: number): [number, number] {
318
+ const { area, backdrop } = this.parts;
319
+ const row = backdrop.firstElementChild;
320
+ const height = row === null ? 0 : row.getBoundingClientRect().height;
321
+ if (height <= 0 || area.clientHeight <= 0) return [0, count - 1];
322
+ const top = Math.max(0, area.scrollTop - (this.layout?.topMargin ?? 0));
323
+ const first = Math.min(count - 1, Math.floor(top / height));
324
+ const last = Math.min(count - 1, Math.max(first, Math.ceil((top + area.clientHeight) / height) - 1));
325
+ return [first, last];
326
+ }
327
+
328
+ private cellElement(cell: GutterCell): HTMLElement {
329
+ const element = document.createElement('div');
330
+ element.className = 'gsv-cell';
331
+ if (cell.runs === null) {
332
+ element.textContent = cell.text;
333
+ return element;
334
+ }
335
+ for (const run of cell.runs) {
336
+ const span = document.createElement('span');
337
+ span.textContent = run.text;
338
+ if (run.bold) span.style.fontWeight = 'bold';
339
+ if (run.italic) span.style.fontStyle = 'italic';
340
+ const decorations = [run.underline ? 'underline' : '', run.strikethrough ? 'line-through' : ''];
341
+ span.style.textDecoration = decorations.filter(Boolean).join(' ');
342
+ element.append(span);
343
+ }
344
+ return element;
345
+ }
346
+
347
+ private columnElement(column: GutterColumn, first: number): HTMLElement {
348
+ const element = document.createElement('div');
349
+ element.className = 'gsv-column';
350
+ element.style.minWidth = `${column.widthRequest}px`;
351
+ element.style.paddingLeft = `${column.marginStart}px`;
352
+ element.style.paddingRight = `${column.marginEnd}px`;
353
+ const spacer = document.createElement('div');
354
+ spacer.style.height = `calc(${first} * 1em * var(--gsv-line-height))`;
355
+ element.append(spacer, ...column.cells.map((cell) => this.cellElement(cell)));
356
+ return element;
357
+ }
358
+
359
+ /** The gutter: the renderers left of the line numbers, the numbers, the renderers right of them. */
360
+ private renderGutter(): void {
361
+ const { gutter, rightGutter, root, area } = this.parts;
362
+ const host = this.host;
363
+ const count = this.text.split('\n').length;
364
+ const [first, last] = this.visibleLines(count);
365
+ const columns = host?.queryGutter(first, last) ?? [];
366
+ this.hasColumns = columns.length > 0;
367
+ const showNumbers = this.layout?.showLineNumbers === true;
368
+ root.classList.toggle('has-columns', this.hasColumns);
369
+ const elements: HTMLElement[] = [];
370
+ for (const column of columns.filter((c) => c.position < 0)) elements.push(this.columnElement(column, first));
371
+ if (showNumbers && this.numbers !== null) elements.push(this.numbers);
372
+ for (const column of columns.filter((c) => c.position >= 0)) elements.push(this.columnElement(column, first));
373
+ const wrapper = document.createElement('div');
374
+ wrapper.className = 'gsv-columns';
375
+ wrapper.append(...elements);
376
+ wrapper.style.transform = `translateY(${-area.scrollTop}px)`;
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);
259
388
  }
260
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
 
@@ -110,3 +110,4 @@ export { AdwWindow as Window } from '../elements/adw-window.js';
110
110
  export { AdwWindowTitle as WindowTitle } from '../elements/adw-window-title.js';
111
111
  export { AdwWrapBox as WrapBox } from '../elements/adw-wrap-box.js';
112
112
  export { AdwApplication as Application } from '../application.js';
113
+ export { AdwStyleManager as StyleManager } from '../style-manager.js';
@@ -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;
@@ -12,10 +12,10 @@
12
12
  // `check-vocabulary-alignment.mjs` derives their members from the elements this package
13
13
  // defines. GIO defines none: what lands here is the handful of GObject types an Adwaita
14
14
  // author CONSTRUCTS while building a tree — today the menu a `menuModel` property takes.
15
- // `Gio.File`, `Gio.Settings`, `Gio.SimpleAction` and the rest of the library are NOT this
15
+ // `Gio.File`, `Gio.Settings` and the rest of the library are NOT this
16
16
  // package's business; a namespace that grows past what the elements consume is a second,
17
17
  // unheld vocabulary.
18
18
 
19
19
  export { GioMenu as Menu, GioMenuItem as MenuItem } from '@gjsify/adwaita-core';
20
- export { SimpleAction, ApplicationFlags } from '@gjsify/adwaita-core';
20
+ export { SimpleAction, SimpleActionGroup, ApplicationFlags } from '@gjsify/adwaita-core';
21
21
  export { GioApplication as Application } from '../application.js';
@@ -3,10 +3,20 @@
3
3
  // behaviour lives once in `@gjsify/adwaita-core` (`glib-timers.ts`, which states where a host
4
4
  // event loop cannot honour GLib); this file is the namespace door over the host's `setTimeout`.
5
5
  //
6
+ // The path helpers (`MAXUINT32`, `build_filenamev`, `get_current_dir`, `get_system_data_dirs`) live
7
+ // in `glib-paths.ts` of the core; the host has no filesystem, so `get_current_dir()` is `"/"` and
8
+ // `get_system_data_dirs()` is `[]`. A port that knows better replaces the member in its own file.
9
+ //
6
10
  // A TRUE SUBSET: everything else in GLib (`MainLoop`, `Source`, `timeout_add_seconds`, `Bytes`,
7
- // `build_filenamev`, …) is absent, and the `gi://GLib` arm refuses it by name.
11
+ // `get_home_dir`, …) is absent, and the `gi://GLib` arm refuses it by name.
8
12
 
9
- import { createGLibTimers } from '@gjsify/adwaita-core';
13
+ import {
14
+ buildFilenamev,
15
+ createGLibTimers,
16
+ getCurrentDir,
17
+ getSystemDataDirs,
18
+ MAXUINT32 as GLIB_MAXUINT32,
19
+ } from '@gjsify/adwaita-core';
10
20
 
11
21
  const glib = createGLibTimers();
12
22
 
@@ -20,3 +30,8 @@ export const SOURCE_CONTINUE = glib.SOURCE_CONTINUE;
20
30
  export const idle_add = glib.idle_add;
21
31
  export const timeout_add = glib.timeout_add;
22
32
  export const source_remove = glib.source_remove;
33
+
34
+ export const MAXUINT32 = GLIB_MAXUINT32;
35
+ export const build_filenamev = buildFilenamev;
36
+ export const get_current_dir = getCurrentDir;
37
+ export const get_system_data_dirs = getSystemDataDirs;
@@ -18,8 +18,10 @@ const GObjectObject = gobject.Object;
18
18
  export { GObjectObject as Object };
19
19
  export const registerClass = gobject.registerClass;
20
20
  export const type_ensure = gobject.type_ensure;
21
+ export const signal_stop_emission_by_name = gobject.signal_stop_emission_by_name;
21
22
  export const ParamSpec = gobject.ParamSpec;
22
23
  export const ParamFlags = gobject.ParamFlags;
24
+ export const Value = gobject.Value;
23
25
  export const TYPE_STRING = gobject.TYPE_STRING;
24
26
  export const TYPE_BOOLEAN = gobject.TYPE_BOOLEAN;
25
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);