flexdesk 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -875,15 +875,27 @@ function _match(text, query) {
875
875
  const sub = t.indexOf(q);
876
876
  if (sub >= 0) return { s: 60, hits: span(sub, q.length) };
877
877
 
878
+ // TIGHT OR NOT AT ALL. A subsequence is for an abbreviation — "cstmr",
879
+ // "ord ln" — not for any label that happens to hold the letters in order:
880
+ // with a loose bound, "cour" found "Scroll up to: 1,000 rows" and "new"
881
+ // found "Below the pane", and those rows buried the ones meant. So the
882
+ // match starts at a word start, a jump that lands on a word start is free
883
+ // (that is what an abbreviation is), and every other skipped letter counts:
884
+ // together they may be half the query, no more.
878
885
  const flat = q.replace(/\s+/g, '');
886
+ const startsWord = (i) => i === 0 || _WORD_BREAK.test(t[i - 1]);
879
887
  const hits = [];
880
888
  let j = 0;
881
889
  for (let i = 0; i < t.length && j < flat.length; i++) {
882
890
  if (t[i] === flat[j]) { hits.push(i); j++; }
883
891
  }
884
- if (j < flat.length) return null;
885
- const spread = hits[hits.length - 1] - hits[0] + 1 - flat.length;
886
- if (spread > 3 * flat.length + 6) return null; // letters strewn across a long label
892
+ if (j < flat.length || !startsWord(hits[0])) return null;
893
+ let spread = 0;
894
+ for (let k = 1; k < hits.length; k++) {
895
+ const gap = hits[k] - hits[k - 1] - 1;
896
+ if (gap > 0 && !startsWord(hits[k])) spread += gap;
897
+ }
898
+ if (spread > Math.floor(flat.length / 2)) return null;
887
899
  return { s: Math.max(1, 30 - spread), hits };
888
900
  }
889
901
 
@@ -47,6 +47,9 @@ function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
47
47
  // when it asked for nothing. Names are assigned by wm._canonicalize via
48
48
  // PANEL_TITLES.
49
49
  panels: { ...panelDefaults },
50
+ // C40. Both unset is a desktop exactly as it always was.
51
+ group: null,
52
+ fixed: false,
50
53
  };
51
54
  }
52
55
 
@@ -66,6 +69,9 @@ export class DesktopManager {
66
69
  this.panelDefaults = { ...DEFAULT_PANEL_STATE, ...(panelDefaults || {}) };
67
70
  this.desktops = [_makeDesktop('1', seed, this.panelDefaults)];
68
71
  this.activeIdx = 0;
72
+ // C40. group → the id of the member last on screen, which is where the
73
+ // group's chip goes back to.
74
+ this.lastInGroup = new Map();
69
75
  }
70
76
 
71
77
  active() { return this.desktops[this.activeIdx]; }
@@ -74,9 +80,62 @@ export class DesktopManager {
74
80
  if (idx < 0 || idx >= this.desktops.length) return false;
75
81
  if (idx === this.activeIdx) return false;
76
82
  this.activeIdx = idx;
83
+ const d = this.desktops[idx];
84
+ if (d.group) this.lastInGroup.set(d.group, d.id);
77
85
  return true;
78
86
  }
79
87
 
88
+ /**
89
+ * ══ C40. WHAT THE DESKTOP BAR DRAWS — ONE CHIP PER GROUP ═══════════════
90
+ *
91
+ * An embedder may give desktops a `group` and mark them `fixed`. Desktops
92
+ * that share a group are ONE stop for the user — one chip in the bar, one
93
+ * step of Ctrl+Left/Right — and the chip goes back to whichever member was
94
+ * last on screen; which member that is, is the embedder's business (Tables
95
+ * keeps one desktop per page under a "Workbench" group, and the rail picks
96
+ * among them). A `fixed` desktop is the embedder's own: the bar offers no
97
+ * rename and no delete for it, and no menu offers it as a "Move to"
98
+ * destination.
99
+ *
100
+ * With no group set anywhere this is one chip per desktop, in order — the
101
+ * bar every existing embedder has.
102
+ *
103
+ * @returns {Array<{ idx: number, label: string, group: string|null,
104
+ * fixed: boolean, active: boolean, members: number[] }>} `idx` is the
105
+ * desktop the chip switches to.
106
+ */
107
+ chips() {
108
+ const out = [];
109
+ const byGroup = new Map();
110
+ this.desktops.forEach((d, i) => {
111
+ if (!d.group) {
112
+ out.push({ idx: i, label: d.label, group: null, fixed: !!d.fixed,
113
+ active: i === this.activeIdx, members: [i] });
114
+ return;
115
+ }
116
+ let chip = byGroup.get(d.group);
117
+ if (!chip) {
118
+ chip = { idx: i, label: d.group, group: d.group, fixed: true,
119
+ active: false, members: [] };
120
+ byGroup.set(d.group, chip);
121
+ out.push(chip);
122
+ }
123
+ chip.members.push(i);
124
+ if (i === this.activeIdx) chip.active = true;
125
+ });
126
+ for (const chip of byGroup.values()) {
127
+ if (chip.active) { chip.idx = this.activeIdx; continue; }
128
+ const last = this.lastInGroup.get(chip.group);
129
+ const at = last ? this.desktops.findIndex((d) => d.id === last) : -1;
130
+ if (at >= 0 && this.desktops[at].group === chip.group) chip.idx = at;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** C40. Whether any desktop belongs to a group — the bar's cue to stop
136
+ * promising "Alt+N" on its chips, which then no longer count desktops. */
137
+ hasGroups() { return this.desktops.some((d) => !!d.group); }
138
+
80
139
  ensureCount(n) {
81
140
  while (this.desktops.length < n) {
82
141
  this.desktops.push(_makeDesktop(
@@ -85,8 +144,10 @@ export class DesktopManager {
85
144
  }
86
145
 
87
146
  addDesktop(label = null) {
88
- const d = _makeDesktop(
89
- label || String(this.desktops.length + 1), this.seed, this.panelDefaults);
147
+ // C40. Numbered among the desktops a user makes, so the first one added
148
+ // beside an embedder's grouped desktops is "1" rather than "13".
149
+ const n = this.desktops.filter((d) => !d.group).length + 1;
150
+ const d = _makeDesktop(label || String(n), this.seed, this.panelDefaults);
90
151
  this.desktops.push(d);
91
152
  return d;
92
153
  }
@@ -98,6 +159,10 @@ export class DesktopManager {
98
159
  id: d.id, label: d.label,
99
160
  tree: d.tree.serialize(),
100
161
  panels: { ...d.panels },
162
+ // C40. Written only when set, so a blob without them is
163
+ // byte-for-byte the blob it always was.
164
+ ...(d.group ? { group: d.group } : {}),
165
+ ...(d.fixed ? { fixed: true } : {}),
101
166
  // windows serialized at WM level since we don't own
102
167
  // managed-window state in this module.
103
168
  })),
@@ -117,12 +182,16 @@ export class DesktopManager {
117
182
  // this merge order avoids. The default only fills a key the stored
118
183
  // blob predates.
119
184
  panels: { ...m.panelDefaults, ...(raw.panels || {}) },
185
+ group: raw.group || null,
186
+ fixed: !!raw.fixed,
120
187
  }));
121
188
  // Empty tree → seed with the taxonomy root so the desktop is usable.
122
189
  for (const d of m.desktops) {
123
190
  if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
124
191
  }
125
192
  m.activeIdx = Math.min(Math.max(0, blob.activeIdx | 0), m.desktops.length - 1);
193
+ const active = m.desktops[m.activeIdx];
194
+ if (active.group) m.lastInGroup.set(active.group, active.id);
126
195
  return m;
127
196
  }
128
197
  }
@@ -118,6 +118,13 @@ import { openForm } from '../ui/components/modal.js';
118
118
  * whose new tab has nowhere useful to go
119
119
  * passes `false`. `wm.newTab()` is there
120
120
  * either way.
121
+ * @param {object} [cfg.desktopBar] C40. `{ switchOnAdd?: boolean }` — the
122
+ * bar's `+` also puts the new desktop on
123
+ * screen. Default off: `+` only adds, as
124
+ * it always has. Grouping and fixing
125
+ * desktops is said on the desktops
126
+ * themselves (`group`, `fixed` — see
127
+ * `DesktopManager.chips`).
121
128
  * @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
122
129
  * panelToggles?: { left?, right?, bottom? },
123
130
  * zoom? }
@@ -147,6 +154,7 @@ export async function createShell({
147
154
  floatActiveTab = false,
148
155
  tabLayout = null,
149
156
  newTabButton = true,
157
+ desktopBar = {},
150
158
  chrome = {},
151
159
  // An embedder that moved its sections out of the top bar — into an icon
152
160
  // rail, say — passes the selector its own buttons match, and F1..F8 keep
@@ -217,7 +225,7 @@ export async function createShell({
217
225
  // key means absent chrome, not a crash.
218
226
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
219
227
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
220
- desktopsEl = mountDesktopBar(chrome.desktops, wm);
228
+ desktopsEl = mountDesktopBar(chrome.desktops, wm, desktopBar || {});
221
229
  // C31. The content zoom. Painted with the rest of the chrome and, unlike the
222
230
  // rest, AWAITED before the first mount: its saved value is applied to `root`
223
231
  // as a CSS variable, and a shell that mounted first would paint every tile at
@@ -276,6 +284,12 @@ export async function createShell({
276
284
  // the same root must not inherit the old one's scale with no control on
277
285
  // screen to change it, so dispose puts the root back to 100%.
278
286
  try { zoom?.dispose(); } catch { /* ignore */ }
287
+ // C40. The bar's listeners close over THIS wm, and `mountDesktopBar`
288
+ // reuses an existing `#twm-desktops` — so a rebuilt shell would
289
+ // inherit them, and a `+` would add a desktop to the dead wm and
290
+ // persist it under the host the old shell was given. The element
291
+ // goes; the next shell paints its own.
292
+ try { desktopsEl?.remove(); } catch { /* ignore */ }
279
293
  },
280
294
  });
281
295
  }
@@ -390,7 +404,7 @@ function syncPanelToggles(map, wm) {
390
404
  }
391
405
  }
392
406
 
393
- function mountDesktopBar(hostEl, wm) {
407
+ function mountDesktopBar(hostEl, wm, { switchOnAdd = false } = {}) {
394
408
  if (!hostEl) return null;
395
409
  let el = hostEl.querySelector('#twm-desktops');
396
410
  if (!el) {
@@ -403,15 +417,18 @@ function mountDesktopBar(hostEl, wm) {
403
417
  const btn = e.target.closest('[data-desktop]');
404
418
  if (!btn) return;
405
419
  const v = btn.dataset.desktop;
406
- if (v === '+') { wm.addDesktop(); syncDesktopBar(el, wm); return; }
420
+ if (v === '+') { wm.addDesktop({ switchTo: switchOnAdd }); syncDesktopBar(el, wm); return; }
407
421
  wm.switchDesktop(Number(v));
408
422
  });
423
+ // C40. A fixed desktop, or a group's chip, is the embedder's: no rename,
424
+ // no delete. Asked of the button, which says which it is.
425
+ const isOwn = (btn) => btn.dataset.desktopFixed !== '1';
409
426
  // Double-click a desktop chip → inline rename. Faster than the context
410
427
  // menu's Rename… for the common case. Persists via wm._persist so the new
411
428
  // label survives a restart.
412
429
  el.addEventListener('dblclick', async (e) => {
413
430
  const btn = e.target.closest('[data-desktop]');
414
- if (!btn || btn.dataset.desktop === '+') return;
431
+ if (!btn || btn.dataset.desktop === '+' || !isOwn(btn)) return;
415
432
  e.preventDefault();
416
433
  const idx = Number(btn.dataset.desktop);
417
434
  const d = wm.desktops.desktops[idx];
@@ -435,16 +452,19 @@ function mountDesktopBar(hostEl, wm) {
435
452
  const idx = Number(btn.dataset.desktop);
436
453
  const m = wm.desktops;
437
454
  const d = m.desktops[idx];
438
- const canDelete = m.desktops.length > 1;
439
- showContextMenu(e.clientX, e.clientY, [
440
- { label: `Switch to ${d.label}`, icon: 'desktop_windows',
455
+ const own = isOwn(btn);
456
+ const canDelete = own && m.desktops.length > 1;
457
+ const name = btn.dataset.desktopLabel || d.label;
458
+ const items = [
459
+ { label: `Switch to ${name}`, icon: 'desktop_windows',
441
460
  action: 'switch', disabled: idx === m.activeIdx },
442
- { label: 'Rename…', icon: 'edit', action: 'rename' },
461
+ ...(own ? [{ label: 'Rename…', icon: 'edit', action: 'rename' }] : []),
443
462
  { separator: true },
444
463
  { label: 'New desktop', icon: 'add', action: 'add' },
445
- { label: 'Delete desktop', icon: 'delete', action: 'delete',
446
- danger: true, disabled: !canDelete },
447
- ], async (action) => {
464
+ ...(own ? [{ label: 'Delete desktop', icon: 'delete', action: 'delete',
465
+ danger: true, disabled: !canDelete }] : []),
466
+ ];
467
+ showContextMenu(e.clientX, e.clientY, items, async (action) => {
448
468
  if (action === 'switch') wm.switchDesktop(idx);
449
469
  else if (action === 'rename') {
450
470
  const result = await openForm({
@@ -458,7 +478,7 @@ function mountDesktopBar(hostEl, wm) {
458
478
  syncDesktopBar(el, wm);
459
479
  wm._persist();
460
480
  }
461
- else if (action === 'add') { wm.addDesktop(); syncDesktopBar(el, wm); }
481
+ else if (action === 'add') { wm.addDesktop({ switchTo: switchOnAdd }); syncDesktopBar(el, wm); }
462
482
  else if (action === 'delete') { wm.removeDesktop(idx); syncDesktopBar(el, wm); }
463
483
  });
464
484
  });
@@ -468,15 +488,40 @@ function mountDesktopBar(hostEl, wm) {
468
488
  function syncDesktopBar(el, wm) {
469
489
  if (!el) return;
470
490
  const m = wm.desktops;
471
- el.innerHTML =
472
- m.desktops.map((d, i) => `
491
+ // C40. Ungrouped, this is the markup it always was, byte for byte. Once a
492
+ // group exists the chips no longer count desktops, so the Alt+N promise
493
+ // goes, and a label is escaped because a group's comes from the embedder.
494
+ if (!m.hasGroups()) {
495
+ el.innerHTML =
496
+ m.desktops.map((d, i) => `
473
497
  <button class="twm-desk__btn${i === m.activeIdx ? ' twm-desk__btn--on' : ''}"
474
498
  data-desktop="${i}"
475
499
  title="Desktop ${d.label} (Alt+${i + 1}) · right-click to delete">
476
500
  ${d.label}
477
501
  </button>`).join('')
478
- + `<button class="twm-desk__btn twm-desk__btn--add"
502
+ + `<button class="twm-desk__btn twm-desk__btn--add"
479
503
  data-desktop="+" title="Add desktop">+</button>`;
504
+ return;
505
+ }
506
+ el.innerHTML =
507
+ m.chips().map((c) => {
508
+ const label = _escapeHtml(c.label);
509
+ const title = c.fixed ? label : `${label} · double-click to rename · right-click for more`;
510
+ return `
511
+ <button class="twm-desk__btn${c.active ? ' twm-desk__btn--on' : ''}"
512
+ data-desktop="${c.idx}"
513
+ data-desktop-label="${label}"${c.fixed ? ' data-desktop-fixed="1"' : ''}
514
+ title="${title}">
515
+ ${label}
516
+ </button>`;
517
+ }).join('')
518
+ + `<button class="twm-desk__btn twm-desk__btn--add"
519
+ data-desktop="+" title="New desktop">+</button>`;
520
+ }
521
+
522
+ function _escapeHtml(s) {
523
+ return String(s ?? '').replace(/[&<>"']/g, (ch) => (
524
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
480
525
  }
481
526
 
482
527
 
@@ -556,7 +601,8 @@ function _tileContextMenu(wm, leafId, x, y) {
556
601
  ];
557
602
  if (wm.desktops.desktops.length > 1 && !isPanel) {
558
603
  for (const [i, d] of wm.desktops.desktops.entries()) {
559
- if (i === wm.desktops.activeIdx) continue;
604
+ // C40. Never onto a fixed desktop — `moveFocusedToDesktop` refuses.
605
+ if (i === wm.desktops.activeIdx || d.fixed) continue;
560
606
  items.push({ label: `Move to desktop ${d.label}`, icon: 'sweep',
561
607
  action: `move:${i}` });
562
608
  }
package/src/tiling/wm.js CHANGED
@@ -2789,6 +2789,8 @@ export class WindowManager {
2789
2789
  const rec = this._windowToLeaf.get(windowId);
2790
2790
  if (!rec) return;
2791
2791
  if (rec.desktopIdx === targetIdx) return;
2792
+ // C40. A fixed desktop is the embedder's, and nothing is moved onto it.
2793
+ if (this.desktops.desktops[targetIdx]?.fixed) return;
2792
2794
  this.desktops.ensureCount(targetIdx + 1);
2793
2795
  rec.desktopIdx = targetIdx;
2794
2796
  if (!rec.homeContainer && rec.homeLeafId) {
@@ -2957,10 +2959,12 @@ export class WindowManager {
2957
2959
  const items = [
2958
2960
  { label: 'Back to tile', icon: 'close_fullscreen', action: 'back' },
2959
2961
  ];
2960
- if (this.desktops.desktops.length > 1) {
2962
+ // C40. Never onto a fixed desktop — `moveWindowToDesktop` refuses.
2963
+ const targets = [...this.desktops.desktops.entries()]
2964
+ .filter(([i, d]) => i !== rec.desktopIdx && !d.fixed);
2965
+ if (targets.length) {
2961
2966
  items.push({ separator: true });
2962
- for (const [i, d] of this.desktops.desktops.entries()) {
2963
- if (i === rec.desktopIdx) continue;
2967
+ for (const [i, d] of targets) {
2964
2968
  items.push({
2965
2969
  label: `Move to desktop ${d.label}`, icon: 'sweep',
2966
2970
  action: `move:${i}`,
@@ -3297,21 +3301,32 @@ export class WindowManager {
3297
3301
  this._notifyChange();
3298
3302
  }
3299
3303
 
3300
- addDesktop() {
3304
+ /** Add a desktop. C40: `{ switchTo: true }` also puts it on screen, which
3305
+ * is what the bar's `+` does under `createShell({ desktopBar: {
3306
+ * switchOnAdd: true } })`. Answers the new desktop's index. */
3307
+ addDesktop({ switchTo = false } = {}) {
3301
3308
  this.desktops.addDesktop();
3309
+ const idx = this.desktops.desktops.length - 1;
3310
+ if (switchTo) { this.switchDesktop(idx); return idx; }
3302
3311
  this._notifyChange();
3303
3312
  this._persist();
3313
+ return idx;
3304
3314
  }
3305
3315
 
3306
3316
  /** Step to the previous (-1) or next (+1) existing desktop, wrapping
3307
3317
  * around. No-op with a single desktop. Iterates only desktops that
3308
- * already exist — use switchDesktop(idx) to create-on-demand. */
3318
+ * already exist — use switchDesktop(idx) to create-on-demand.
3319
+ *
3320
+ * C40. It steps between the bar's CHIPS, so a group is one stop and
3321
+ * lands on its member last on screen. Ungrouped, a chip is a desktop
3322
+ * and this is the step it always was. */
3309
3323
  cycleDesktop(dir) {
3310
- const m = this.desktops;
3311
- const n = m.desktops.length;
3324
+ const chips = this.desktops.chips();
3325
+ const n = chips.length;
3312
3326
  if (n <= 1) return;
3313
- const next = (m.activeIdx + (dir < 0 ? -1 : 1) + n) % n;
3314
- this.switchDesktop(next);
3327
+ const at = chips.findIndex((c) => c.active);
3328
+ const next = chips[(at + (dir < 0 ? -1 : 1) + n) % n];
3329
+ this.switchDesktop(next.idx);
3315
3330
  }
3316
3331
 
3317
3332
  /** Cycle the focused tile's main-panel tabs (Ctrl+Tab / Ctrl+Shift+Tab).
@@ -3350,6 +3365,8 @@ export class WindowManager {
3350
3365
  const m = this.desktops;
3351
3366
  if (m.desktops.length <= 1) return false;
3352
3367
  if (idx < 0 || idx >= m.desktops.length) return false;
3368
+ // C40. A fixed desktop is the embedder's to keep.
3369
+ if (m.desktops[idx].fixed) return false;
3353
3370
  m.desktops.splice(idx, 1);
3354
3371
  // THE SPLICE MOVES THE ACTIVE DESKTOP TOO, and the clamp below never
3355
3372
  // said so. Removing a desktop BELOW the one you are on shifts it down
@@ -3399,13 +3416,36 @@ export class WindowManager {
3399
3416
  // says "Move".
3400
3417
  const closeChrome = this.renderer?.leafChrome?.(focused.id)?.close;
3401
3418
  if (closeChrome === false || closeChrome?.disabled === true) return;
3402
- const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
3419
+ // C40. A fixed desktop is the embedder's, and nothing is moved onto it.
3420
+ if (this.desktops.desktops[idx]?.fixed) return;
3403
3421
  this.desktops.ensureCount(idx + 1);
3404
3422
  const target = this.desktops.desktops[idx];
3405
- // Place into the target tree's primary leaf.
3423
+ // C40. THE PANE'S TABS JOIN THE TARGET'S PRIMARY LEAF, ALL OF THEM. It
3424
+ // used to `setLeafContent` the ACTIVE tab over that leaf — which threw
3425
+ // away every other tab of the pane being moved AND whatever the target
3426
+ // was showing, so moving a pane onto a desktop you had worked in wiped
3427
+ // that desktop's main pane. Only a target still holding nothing but the
3428
+ // seed it was born with is replaced, because that is a blank page.
3429
+ const tabs = (Array.isArray(focused.tabs) && focused.tabs.length)
3430
+ ? focused.tabs
3431
+ : [{ kind: focused.content.kind, props: focused.content.props, title: focused.title }];
3432
+ const activeTab = Math.max(0, Math.min(tabs.length - 1, focused.activeTabIdx || 0));
3406
3433
  const targetTree = target.tree;
3407
3434
  const primary = targetTree.primaryLeafId();
3408
- if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
3435
+ if (primary) {
3436
+ const dest = targetTree.get(primary);
3437
+ const seedKind = this._rootLeaf()?.content?.kind;
3438
+ const blank = (dest.tabs || []).length <= 1 && dest.content?.kind === seedKind;
3439
+ let first = -1;
3440
+ tabs.forEach((tab, i) => {
3441
+ const content = { kind: tab.kind, props: tab.props || {} };
3442
+ const title = tab.title || tab.kind || '';
3443
+ if (i === 0 && blank) { targetTree.setLeafContent(primary, content, title); first = 0; return; }
3444
+ const at = targetTree.appendLeafTab(primary, content, title);
3445
+ if (first < 0) first = at;
3446
+ });
3447
+ if (first >= 0) targetTree.setActiveLeafTab(primary, first + activeTab);
3448
+ }
3409
3449
  // Remove from current desktop.
3410
3450
  tree.close(focused.id);
3411
3451
  if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));