restty 0.1.19 → 0.1.21

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/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![CI](https://img.shields.io/github/actions/workflow/status/wiedymi/restty/ci.yml?branch=main&style=flat-square)](https://github.com/wiedymi/restty/actions/workflows/ci.yml)
6
6
  [![Publish](https://img.shields.io/github/actions/workflow/status/wiedymi/restty/publish.yml?style=flat-square&label=publish)](https://github.com/wiedymi/restty/actions/workflows/publish.yml)
7
7
  [![Demo](https://img.shields.io/badge/demo-restty.pages.dev-0ea5e9?style=flat-square)](https://restty.pages.dev/)
8
+
8
9
  [![GitHub](https://img.shields.io/badge/-GitHub-181717?style=flat-square&logo=github&logoColor=white)](https://github.com/wiedymi)
9
10
  [![Twitter](https://img.shields.io/badge/-Twitter-1DA1F2?style=flat-square&logo=twitter&logoColor=white)](https://x.com/wiedymi)
10
11
  [![Email](https://img.shields.io/badge/-Email-EA4335?style=flat-square&logo=gmail&logoColor=white)](mailto:contact@wiedymi.com)
@@ -101,7 +102,7 @@ restty.copySelectionToClipboard();
101
102
 
102
103
  ### Provide custom fonts
103
104
 
104
- By default, restty uses a CDN font preset. To fully control fonts, disable the preset and pass `fontSources`.
105
+ By default, restty uses a local-first font preset with CDN fallback. To fully control fonts, disable the preset and pass `fontSources`.
105
106
 
106
107
  ```ts
107
108
  const restty = new Restty({
@@ -1,5 +1,5 @@
1
1
  import type { ResttyFontPreset, ResttyFontSource } from "./types";
2
- /** Default font fallback chain with JetBrains Mono, Nerd Fonts symbols, Noto symbols, color emoji, black emoji, and CJK support. */
2
+ /** Local-first default font fallback chain with CDN fallback for JetBrains Mono, Nerd symbols, emoji, and CJK support. */
3
3
  export declare const DEFAULT_FONT_SOURCES: ResttyFontSource[];
4
4
  /** Validates user-provided font sources or returns defaults based on preset (none returns empty array, otherwise default CDN fonts). */
5
5
  export declare function normalizeFontSources(sources: ResttyFontSource[] | undefined, preset: ResttyFontPreset | undefined): ResttyFontSource[];
@@ -154,6 +154,12 @@ export type ResttyAppOptions = {
154
154
  renderer?: "auto" | "webgpu" | "webgl2";
155
155
  /** Font size in CSS pixels. */
156
156
  fontSize?: number;
157
+ /**
158
+ * Font sizing mode used by text-shaper scale resolution.
159
+ * - em: interpret fontSize as EM size
160
+ * - height: interpret fontSize as full font height (ascender-descender-lineGap)
161
+ */
162
+ fontSizeMode?: "em" | "height";
157
163
  /**
158
164
  * Alpha blending strategy.
159
165
  * - native: GPU-native premultiplied alpha
@@ -9095,6 +9095,37 @@ function isLikelyEmojiCodepoint(cp) {
9095
9095
  return true;
9096
9096
  return false;
9097
9097
  }
9098
+ function isVariationSelectorCodepoint(cp) {
9099
+ if (cp >= 65024 && cp <= 65039)
9100
+ return true;
9101
+ if (cp >= 917760 && cp <= 917999)
9102
+ return true;
9103
+ return false;
9104
+ }
9105
+ function isCombiningMarkCodepoint(cp) {
9106
+ if (cp >= 768 && cp <= 879)
9107
+ return true;
9108
+ if (cp >= 6832 && cp <= 6911)
9109
+ return true;
9110
+ if (cp >= 7616 && cp <= 7679)
9111
+ return true;
9112
+ if (cp >= 8400 && cp <= 8447)
9113
+ return true;
9114
+ if (cp >= 65056 && cp <= 65071)
9115
+ return true;
9116
+ return false;
9117
+ }
9118
+ function isCoverageIgnorableCodepoint(cp) {
9119
+ if (cp === 8204 || cp === 8205)
9120
+ return true;
9121
+ if (isVariationSelectorCodepoint(cp))
9122
+ return true;
9123
+ if (isCombiningMarkCodepoint(cp))
9124
+ return true;
9125
+ if (cp >= 917536 && cp <= 917631)
9126
+ return true;
9127
+ return false;
9128
+ }
9098
9129
  function resolvePresentationPreference(text, chars) {
9099
9130
  if (text.includes("️"))
9100
9131
  return "emoji";
@@ -9109,7 +9140,7 @@ function resolvePresentationPreference(text, chars) {
9109
9140
  }
9110
9141
  return "auto";
9111
9142
  }
9112
- function pickFontIndexForText(state, text, expectedSpan, shapeClusterWithFont) {
9143
+ function pickFontIndexForText(state, text, expectedSpan) {
9113
9144
  if (!state.fonts.length)
9114
9145
  return 0;
9115
9146
  const cacheKey = `${expectedSpan}:${text}`;
@@ -9117,6 +9148,10 @@ function pickFontIndexForText(state, text, expectedSpan, shapeClusterWithFont) {
9117
9148
  if (cached !== undefined)
9118
9149
  return cached;
9119
9150
  const chars = Array.from(text);
9151
+ const requiredChars = chars.filter((ch) => {
9152
+ const cp = ch.codePointAt(0) ?? 0;
9153
+ return !isCoverageIgnorableCodepoint(cp);
9154
+ });
9120
9155
  const firstCp = text.codePointAt(0) ?? 0;
9121
9156
  const nerdSymbol = isNerdSymbolCodepoint(firstCp);
9122
9157
  const presentation = resolvePresentationPreference(text, chars);
@@ -9128,7 +9163,7 @@ function pickFontIndexForText(state, text, expectedSpan, shapeClusterWithFont) {
9128
9163
  if (predicate && !predicate(entry))
9129
9164
  continue;
9130
9165
  let ok = true;
9131
- for (const ch of chars) {
9166
+ for (const ch of requiredChars) {
9132
9167
  if (!fontHasGlyph(entry.font, ch)) {
9133
9168
  ok = false;
9134
9169
  break;
@@ -26598,7 +26633,7 @@ function buildFontAtlasIfNeeded(params) {
26598
26633
  if (union.size === 0) {
26599
26634
  return { rebuilt: false, atlas: null, rgba: null, preferNearest: false };
26600
26635
  }
26601
- const useHinting = fontIndex === 0 && !isSymbol;
26636
+ const useHinting = false;
26602
26637
  const atlasPadding = isSymbol ? Math.max(constants.atlasPadding, constants.symbolAtlasPadding) : constants.atlasPadding;
26603
26638
  const atlasMaxSize = isSymbol ? constants.symbolAtlasMaxSize : constants.defaultAtlasMaxSize;
26604
26639
  const glyphPixelMode = resolveGlyphPixelMode(entry);
@@ -26690,9 +26725,79 @@ function buildFontAtlasIfNeeded(params) {
26690
26725
 
26691
26726
  // src/app/font-sources.ts
26692
26727
  var DEFAULT_FONT_SOURCES = [
26728
+ {
26729
+ type: "local",
26730
+ matchers: [
26731
+ "jetbrainsmono nerd font",
26732
+ "jetbrains mono nerd font",
26733
+ "jetbrains mono nl nerd font mono",
26734
+ "jetbrains mono",
26735
+ "jetbrainsmono"
26736
+ ],
26737
+ label: "JetBrains Mono Nerd Font Regular (Local)"
26738
+ },
26739
+ {
26740
+ type: "local",
26741
+ matchers: [
26742
+ "jetbrainsmono nerd font bold",
26743
+ "jetbrains mono nerd font bold",
26744
+ "jetbrains mono nl nerd font mono bold",
26745
+ "jetbrains mono bold",
26746
+ "jetbrainsmono bold"
26747
+ ],
26748
+ label: "JetBrains Mono Nerd Font Bold (Local)"
26749
+ },
26750
+ {
26751
+ type: "local",
26752
+ matchers: [
26753
+ "jetbrainsmono nerd font italic",
26754
+ "jetbrains mono nerd font italic",
26755
+ "jetbrains mono nl nerd font mono italic",
26756
+ "jetbrains mono italic",
26757
+ "jetbrainsmono italic"
26758
+ ],
26759
+ label: "JetBrains Mono Nerd Font Italic (Local)"
26760
+ },
26761
+ {
26762
+ type: "local",
26763
+ matchers: [
26764
+ "jetbrainsmono nerd font bold italic",
26765
+ "jetbrains mono nerd font bold italic",
26766
+ "jetbrains mono nl nerd font mono bold italic",
26767
+ "jetbrains mono bold italic",
26768
+ "jetbrainsmono bold italic"
26769
+ ],
26770
+ label: "JetBrains Mono Nerd Font Bold Italic (Local)"
26771
+ },
26772
+ {
26773
+ type: "url",
26774
+ url: "https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/NoLigatures/Regular/JetBrainsMonoNLNerdFontMono-Regular.ttf",
26775
+ label: "JetBrains Mono Nerd Font Regular"
26776
+ },
26777
+ {
26778
+ type: "url",
26779
+ url: "https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/NoLigatures/Bold/JetBrainsMonoNLNerdFontMono-Bold.ttf",
26780
+ label: "JetBrains Mono Nerd Font Bold"
26781
+ },
26693
26782
  {
26694
26783
  type: "url",
26695
- url: "https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/NoLigatures/Regular/JetBrainsMonoNLNerdFontMono-Regular.ttf"
26784
+ url: "https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/NoLigatures/Italic/JetBrainsMonoNLNerdFontMono-Italic.ttf",
26785
+ label: "JetBrains Mono Nerd Font Italic"
26786
+ },
26787
+ {
26788
+ type: "url",
26789
+ url: "https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/NoLigatures/BoldItalic/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf",
26790
+ label: "JetBrains Mono Nerd Font Bold Italic"
26791
+ },
26792
+ {
26793
+ type: "local",
26794
+ matchers: [
26795
+ "symbols nerd font mono",
26796
+ "symbols nerd font",
26797
+ "nerd fonts symbols",
26798
+ "nerdfontssymbolsonly"
26799
+ ],
26800
+ label: "Symbols Nerd Font (Local)"
26696
26801
  },
26697
26802
  {
26698
26803
  type: "url",
@@ -50195,6 +50300,12 @@ var DEFAULT_SYMBOL_CONSTRAINT = {
50195
50300
  align_vertical: "center",
50196
50301
  max_constraint_width: 1
50197
50302
  };
50303
+ var DEFAULT_APPLE_SYMBOLS_CONSTRAINT = {
50304
+ size: "cover",
50305
+ align_horizontal: "center",
50306
+ align_vertical: "center",
50307
+ max_constraint_width: 1
50308
+ };
50198
50309
  var DEFAULT_EMOJI_CONSTRAINT = {
50199
50310
  size: "cover",
50200
50311
  align_horizontal: "center",
@@ -50305,6 +50416,7 @@ function createResttyApp(options) {
50305
50416
  const OVERLAY_SCROLLBAR_MARGIN_CSS_PX = 4;
50306
50417
  const OVERLAY_SCROLLBAR_INSET_Y_CSS_PX = 2;
50307
50418
  const OVERLAY_SCROLLBAR_MIN_THUMB_CSS_PX = 28;
50419
+ const OVERLAY_SCROLLBAR_CAP_SUPERSAMPLE = 8;
50308
50420
  let paused = false;
50309
50421
  let backend = "none";
50310
50422
  let preferredRenderer = options.renderer ?? "auto";
@@ -50627,24 +50739,63 @@ function createResttyApp(options) {
50627
50739
  const y02 = Math.round(y);
50628
50740
  const width = Math.max(1, Math.round(w));
50629
50741
  const height = Math.max(1, Math.round(h));
50630
- const radius = Math.min(Math.floor(width * 0.5), Math.floor(height * 0.5));
50742
+ const radius = Math.min(width * 0.5, height * 0.5);
50631
50743
  if (radius <= 0) {
50632
50744
  pushRectBox(out, x02, y02, width, height, color);
50633
50745
  return;
50634
50746
  }
50635
- const middleH = height - radius * 2;
50747
+ const capRows = Math.min(height, Math.max(1, Math.ceil(radius)));
50748
+ const middleStart = capRows;
50749
+ const middleEnd = Math.max(middleStart, height - capRows);
50750
+ const middleH = middleEnd - middleStart;
50636
50751
  if (middleH > 0) {
50637
- pushRectBox(out, x02, y02 + radius, width, middleH, color);
50752
+ pushRectBox(out, x02, y02 + middleStart, width, middleH, color);
50638
50753
  }
50639
50754
  const radiusSq = radius * radius;
50640
- for (let row = 0;row < radius; row += 1) {
50641
- const dy = radius - row - 0.5;
50642
- const inset = Math.max(0, Math.floor(radius - Math.sqrt(Math.max(0, radiusSq - dy * dy))));
50643
- const rowW = width - inset * 2;
50644
- if (rowW <= 0)
50645
- continue;
50646
- pushRectBox(out, x02 + inset, y02 + row, rowW, 1, color);
50647
- pushRectBox(out, x02 + inset, y02 + height - 1 - row, rowW, 1, color);
50755
+ const centerX = width * 0.5;
50756
+ const topCenterY = radius;
50757
+ const bottomCenterY = height - radius;
50758
+ const samplesPerAxis = Math.max(1, OVERLAY_SCROLLBAR_CAP_SUPERSAMPLE | 0);
50759
+ const totalSamples = samplesPerAxis * samplesPerAxis;
50760
+ const invSamples = 1 / totalSamples;
50761
+ const alphaBase = color[3];
50762
+ const alphaEpsilon = 1 / 255;
50763
+ const sampleCapPixelCoverage = (localX, localY, centerY) => {
50764
+ let hits = 0;
50765
+ for (let sy = 0;sy < samplesPerAxis; sy += 1) {
50766
+ const sampleY = localY + (sy + 0.5) / samplesPerAxis;
50767
+ for (let sx = 0;sx < samplesPerAxis; sx += 1) {
50768
+ const sampleX = localX + (sx + 0.5) / samplesPerAxis;
50769
+ const dx = sampleX - centerX;
50770
+ const dy = sampleY - centerY;
50771
+ if (dx * dx + dy * dy <= radiusSq)
50772
+ hits += 1;
50773
+ }
50774
+ }
50775
+ return hits * invSamples;
50776
+ };
50777
+ for (let row = 0;row < capRows; row += 1) {
50778
+ const topY = y02 + row;
50779
+ const bottomY = y02 + height - 1 - row;
50780
+ for (let col = 0;col < width; col += 1) {
50781
+ const coverageTop = sampleCapPixelCoverage(col, row, topCenterY);
50782
+ if (coverageTop > 0) {
50783
+ const alpha = alphaBase * coverageTop;
50784
+ if (alpha > alphaEpsilon) {
50785
+ out.push(x02 + col, topY, 1, 1, color[0], color[1], color[2], alpha);
50786
+ }
50787
+ }
50788
+ if (bottomY !== topY) {
50789
+ const localBottomY = height - 1 - row;
50790
+ const coverageBottom = sampleCapPixelCoverage(col, localBottomY, bottomCenterY);
50791
+ if (coverageBottom > 0) {
50792
+ const alpha = alphaBase * coverageBottom;
50793
+ if (alpha > alphaEpsilon) {
50794
+ out.push(x02 + col, bottomY, 1, 1, color[0], color[1], color[2], alpha);
50795
+ }
50796
+ }
50797
+ }
50798
+ }
50648
50799
  }
50649
50800
  }
50650
50801
  function appendOverlayScrollbar(overlayData, total, offset, len) {
@@ -51833,7 +51984,7 @@ function createResttyApp(options) {
51833
51984
  font: null,
51834
51985
  fonts: [],
51835
51986
  fontSizePx: 0,
51836
- sizeMode: "height",
51987
+ sizeMode: options.fontSizeMode === "em" ? "em" : "height",
51837
51988
  fontPickCache: new Map
51838
51989
  };
51839
51990
  const fontConfig = {
@@ -53312,6 +53463,33 @@ function createResttyApp(options) {
53312
53463
  const normalizedMatchers = matchers.map((matcher) => matcher.toLowerCase()).filter(Boolean);
53313
53464
  if (!normalizedMatchers.length)
53314
53465
  return null;
53466
+ const detectStyleHint = (value) => {
53467
+ const text = value.toLowerCase();
53468
+ let weight = 400;
53469
+ if (/\b(thin|hairline)\b/.test(text))
53470
+ weight = 100;
53471
+ else if (/\b(extra[- ]?light|ultra[- ]?light)\b/.test(text))
53472
+ weight = 200;
53473
+ else if (/\blight\b/.test(text))
53474
+ weight = 300;
53475
+ else if (/\bmedium\b/.test(text))
53476
+ weight = 500;
53477
+ else if (/\b(semi[- ]?bold|demi[- ]?bold)\b/.test(text))
53478
+ weight = 600;
53479
+ else if (/\bbold\b/.test(text))
53480
+ weight = 700;
53481
+ else if (/\b(extra[- ]?bold|ultra[- ]?bold)\b/.test(text))
53482
+ weight = 800;
53483
+ else if (/\b(black|heavy)\b/.test(text))
53484
+ weight = 900;
53485
+ return {
53486
+ bold: /\b(bold|semi[- ]?bold|demi[- ]?bold|extra[- ]?bold|black|heavy)\b/.test(text),
53487
+ italic: /\b(italic|oblique)\b/.test(text),
53488
+ regular: /\b(regular|book|roman|normal)\b/.test(text),
53489
+ weight
53490
+ };
53491
+ };
53492
+ const sourceHint = detectStyleHint(`${label} ${normalizedMatchers.join(" ")}`);
53315
53493
  const queryPermission = nav.permissions?.query;
53316
53494
  if (queryPermission) {
53317
53495
  try {
@@ -53323,13 +53501,49 @@ function createResttyApp(options) {
53323
53501
  }
53324
53502
  try {
53325
53503
  const fonts = await queryLocalFonts();
53326
- const match = fonts.find((font) => {
53504
+ const matches = fonts.filter((font) => {
53327
53505
  const name = `${font.family ?? ""} ${font.fullName ?? ""} ${font.postscriptName ?? ""}`.toLowerCase();
53328
53506
  return normalizedMatchers.some((matcher) => name.includes(matcher));
53329
53507
  });
53330
- if (match) {
53508
+ if (matches.length) {
53509
+ const scoreMatch = (font) => {
53510
+ const name = `${font.family ?? ""} ${font.fullName ?? ""} ${font.postscriptName ?? ""}`.toLowerCase();
53511
+ const hint = detectStyleHint(name);
53512
+ let score = 0;
53513
+ for (let i3 = 0;i3 < normalizedMatchers.length; i3 += 1) {
53514
+ if (name.includes(normalizedMatchers[i3]))
53515
+ score += 8;
53516
+ }
53517
+ if (sourceHint.bold || sourceHint.italic) {
53518
+ score += sourceHint.bold === hint.bold ? 40 : -40;
53519
+ score += sourceHint.italic === hint.italic ? 40 : -40;
53520
+ } else {
53521
+ score += !hint.bold && !hint.italic ? 60 : -30;
53522
+ }
53523
+ const targetWeight = sourceHint.bold ? 700 : 400;
53524
+ score -= Math.abs((hint.weight ?? 400) - targetWeight) * 0.25;
53525
+ if (!sourceHint.bold && hint.weight === 400)
53526
+ score += 12;
53527
+ if (!sourceHint.bold && hint.weight < 350)
53528
+ score -= 12;
53529
+ if (!sourceHint.bold && hint.weight > 650)
53530
+ score -= 8;
53531
+ if (sourceHint.regular && !hint.bold && !hint.italic)
53532
+ score += 20;
53533
+ return score;
53534
+ };
53535
+ let match = matches[0];
53536
+ let bestScore = Number.NEGATIVE_INFINITY;
53537
+ for (let i3 = 0;i3 < matches.length; i3 += 1) {
53538
+ const candidate = matches[i3];
53539
+ const candidateScore = scoreMatch(candidate);
53540
+ if (candidateScore > bestScore) {
53541
+ bestScore = candidateScore;
53542
+ match = candidate;
53543
+ }
53544
+ }
53331
53545
  const matchedName = `${match.family ?? ""} ${match.fullName ?? ""} ${match.postscriptName ?? ""}`.trim();
53332
- console.log(`[font] local matched (${label}): ${matchedName || "unnamed"}`);
53546
+ console.log(`[font] local matched (${label}): ${matchedName || "unnamed"} score=${bestScore}`);
53333
53547
  const blob = await match.blob();
53334
53548
  return blob.arrayBuffer();
53335
53549
  }
@@ -53536,6 +53750,51 @@ function createResttyApp(options) {
53536
53750
  return true;
53537
53751
  return false;
53538
53752
  }
53753
+ function isVariationSelectorCodepoint2(cp) {
53754
+ if (cp >= 65024 && cp <= 65039)
53755
+ return true;
53756
+ if (cp >= 917760 && cp <= 917999)
53757
+ return true;
53758
+ return false;
53759
+ }
53760
+ function isCombiningMarkCodepoint2(cp) {
53761
+ if (cp >= 768 && cp <= 879)
53762
+ return true;
53763
+ if (cp >= 6832 && cp <= 6911)
53764
+ return true;
53765
+ if (cp >= 7616 && cp <= 7679)
53766
+ return true;
53767
+ if (cp >= 8400 && cp <= 8447)
53768
+ return true;
53769
+ if (cp >= 65056 && cp <= 65071)
53770
+ return true;
53771
+ return false;
53772
+ }
53773
+ function isEmojiModifierCodepoint(cp) {
53774
+ return cp >= 127995 && cp <= 127999;
53775
+ }
53776
+ function isCoverageIgnorableCodepoint2(cp) {
53777
+ if (cp === 8204 || cp === 8205)
53778
+ return true;
53779
+ if (isVariationSelectorCodepoint2(cp))
53780
+ return true;
53781
+ if (isCombiningMarkCodepoint2(cp))
53782
+ return true;
53783
+ if (cp >= 917536 && cp <= 917631)
53784
+ return true;
53785
+ return false;
53786
+ }
53787
+ function shouldMergeTrailingClusterCodepoint(cp) {
53788
+ if (cp === 8204 || cp === 8205)
53789
+ return true;
53790
+ if (isVariationSelectorCodepoint2(cp))
53791
+ return true;
53792
+ if (isCombiningMarkCodepoint2(cp))
53793
+ return true;
53794
+ if (isEmojiModifierCodepoint(cp))
53795
+ return true;
53796
+ return false;
53797
+ }
53539
53798
  function resolvePresentationPreference2(text, chars) {
53540
53799
  if (text.includes("️"))
53541
53800
  return "emoji";
@@ -53550,18 +53809,33 @@ function createResttyApp(options) {
53550
53809
  }
53551
53810
  return "auto";
53552
53811
  }
53553
- function pickFontIndexForText2(text, expectedSpan = 1) {
53812
+ function pickFontIndexForText2(text, expectedSpan = 1, stylePreference = "regular") {
53554
53813
  if (!fontState.fonts.length)
53555
53814
  return 0;
53556
- const cacheKey = `${expectedSpan}:${text}`;
53815
+ const cacheKey = `${expectedSpan}:${stylePreference}:${text}`;
53557
53816
  const cached = fontState.fontPickCache.get(cacheKey);
53558
53817
  if (cached !== undefined)
53559
53818
  return cached;
53560
53819
  const chars = Array.from(text);
53820
+ const requiredChars = chars.filter((ch) => {
53821
+ const cp = ch.codePointAt(0) ?? 0;
53822
+ return !isCoverageIgnorableCodepoint2(cp);
53823
+ });
53561
53824
  const firstCp = text.codePointAt(0) ?? 0;
53562
53825
  const nerdSymbol = isNerdSymbolCodepoint(firstCp);
53563
53826
  const presentation = resolvePresentationPreference2(text, chars);
53564
- const pickFirstMatch = (predicate) => {
53827
+ const styleHintsEnabled = stylePreference !== "regular" && presentation !== "emoji" && !nerdSymbol;
53828
+ const hasBoldHint = (entry) => /\bbold\b/i.test(entry.label ?? "");
53829
+ const hasItalicHint = (entry) => /\b(italic|oblique)\b/i.test(entry.label ?? "");
53830
+ const stylePredicates = stylePreference === "bold_italic" ? [
53831
+ (entry) => hasBoldHint(entry) && hasItalicHint(entry),
53832
+ (entry) => hasBoldHint(entry),
53833
+ (entry) => hasItalicHint(entry)
53834
+ ] : stylePreference === "bold" ? [(entry) => hasBoldHint(entry) && !hasItalicHint(entry), (entry) => hasBoldHint(entry)] : stylePreference === "italic" ? [
53835
+ (entry) => hasItalicHint(entry) && !hasBoldHint(entry),
53836
+ (entry) => hasItalicHint(entry)
53837
+ ] : [];
53838
+ const pickFirstMatch = (predicate, allowSequenceShapingFallback = false) => {
53565
53839
  for (let i3 = 0;i3 < fontState.fonts.length; i3 += 1) {
53566
53840
  const entry = fontState.fonts[i3];
53567
53841
  if (!entry?.font)
@@ -53569,17 +53843,36 @@ function createResttyApp(options) {
53569
53843
  if (predicate && !predicate(entry))
53570
53844
  continue;
53571
53845
  let ok = true;
53572
- for (const ch of chars) {
53846
+ for (const ch of requiredChars) {
53573
53847
  if (!fontHasGlyph2(entry.font, ch)) {
53574
53848
  ok = false;
53575
53849
  break;
53576
53850
  }
53577
53851
  }
53852
+ if (!ok && allowSequenceShapingFallback) {
53853
+ const shaped = shapeClusterWithFont(entry, text);
53854
+ ok = shaped.glyphs.some((glyph) => (glyph.glyphId ?? 0) !== 0);
53855
+ }
53578
53856
  if (ok)
53579
53857
  return i3;
53580
53858
  }
53581
53859
  return -1;
53582
53860
  };
53861
+ const pickWithStyle = (predicate, allowSequenceShapingFallback = false) => {
53862
+ if (styleHintsEnabled) {
53863
+ for (let i3 = 0;i3 < stylePredicates.length; i3 += 1) {
53864
+ const stylePredicate = stylePredicates[i3];
53865
+ const styledIndex = pickFirstMatch((entry) => {
53866
+ if (!stylePredicate(entry))
53867
+ return false;
53868
+ return predicate ? !!predicate(entry) : true;
53869
+ }, allowSequenceShapingFallback);
53870
+ if (styledIndex >= 0)
53871
+ return styledIndex;
53872
+ }
53873
+ }
53874
+ return pickFirstMatch(predicate, allowSequenceShapingFallback);
53875
+ };
53583
53876
  const tryIndex = (index) => {
53584
53877
  if (index < 0)
53585
53878
  return null;
@@ -53587,13 +53880,13 @@ function createResttyApp(options) {
53587
53880
  return index;
53588
53881
  };
53589
53882
  if (nerdSymbol) {
53590
- const symbolIndex = pickFirstMatch((entry) => isNerdSymbolFont(entry) || isSymbolFont(entry));
53883
+ const symbolIndex = pickWithStyle((entry) => isNerdSymbolFont(entry) || isSymbolFont(entry));
53591
53884
  const result = tryIndex(symbolIndex);
53592
53885
  if (result !== null)
53593
53886
  return result;
53594
53887
  }
53595
53888
  if (presentation === "emoji") {
53596
- const emojiIndex = pickFirstMatch((entry) => isColorEmojiFont(entry));
53889
+ const emojiIndex = pickFirstMatch((entry) => isColorEmojiFont(entry), true);
53597
53890
  const result = tryIndex(emojiIndex);
53598
53891
  if (result !== null)
53599
53892
  return result;
@@ -53603,7 +53896,7 @@ function createResttyApp(options) {
53603
53896
  if (result !== null)
53604
53897
  return result;
53605
53898
  }
53606
- const firstIndex = pickFirstMatch();
53899
+ const firstIndex = pickWithStyle();
53607
53900
  if (firstIndex >= 0) {
53608
53901
  setBoundedMap(fontState.fontPickCache, cacheKey, firstIndex, FONT_PICK_CACHE_LIMIT);
53609
53902
  return firstIndex;
@@ -53611,6 +53904,24 @@ function createResttyApp(options) {
53611
53904
  setBoundedMap(fontState.fontPickCache, cacheKey, 0, FONT_PICK_CACHE_LIMIT);
53612
53905
  return 0;
53613
53906
  }
53907
+ function stylePreferenceFromFlags(bold, italic) {
53908
+ if (bold && italic)
53909
+ return "bold_italic";
53910
+ if (bold)
53911
+ return "bold";
53912
+ if (italic)
53913
+ return "italic";
53914
+ return "regular";
53915
+ }
53916
+ function isAppleSymbolsFont(entry) {
53917
+ return !!entry && /\bapple symbols\b/i.test(entry.label ?? "");
53918
+ }
53919
+ function fontEntryHasBoldStyle(entry) {
53920
+ return !!entry && /\bbold\b/i.test(entry.label ?? "");
53921
+ }
53922
+ function fontEntryHasItalicStyle(entry) {
53923
+ return !!entry && /\b(italic|oblique)\b/i.test(entry.label ?? "");
53924
+ }
53614
53925
  function computeCellMetrics2() {
53615
53926
  const primary = fontState.fonts[0];
53616
53927
  if (!primary)
@@ -54285,10 +54596,13 @@ function createResttyApp(options) {
54285
54596
  }
54286
54597
  let nextSeqIdx = idx + baseSpan;
54287
54598
  let guard = 0;
54288
- while ((text.codePointAt(text.length - 1) ?? 0) === 8205 && nextSeqIdx < rowEnd && guard < 8) {
54599
+ while (nextSeqIdx < rowEnd && guard < 12) {
54289
54600
  const next = readCellCluster(nextSeqIdx);
54290
54601
  if (!next || !next.cp || isSpaceCp(next.cp))
54291
54602
  break;
54603
+ const shouldMerge = text.endsWith("‍") || shouldMergeTrailingClusterCodepoint(next.cp);
54604
+ if (!shouldMerge)
54605
+ break;
54292
54606
  text += next.text;
54293
54607
  baseSpan += next.span;
54294
54608
  mergedEmojiSkip[nextSeqIdx] = 1;
@@ -54319,7 +54633,7 @@ function createResttyApp(options) {
54319
54633
  }
54320
54634
  if (extra > 0 && text.trim() === "")
54321
54635
  continue;
54322
- const fontIndex = pickFontIndexForText2(text, baseSpan);
54636
+ const fontIndex = pickFontIndexForText2(text, baseSpan, stylePreferenceFromFlags(bold, italic));
54323
54637
  const fontEntry = fontState.fonts[fontIndex] ?? fontState.fonts[0];
54324
54638
  const shaped = shapeClusterWithFont(fontEntry, text);
54325
54639
  if (!shaped.glyphs.length)
@@ -54588,7 +54902,8 @@ function createResttyApp(options) {
54588
54902
  let y = item.baseY + baselineAdjust - metrics.bearingY * bitmapScale - glyph.yOffset * itemScale;
54589
54903
  if (!glyphConstrained && symbolLike && item.cp) {
54590
54904
  const nerdConstraint = resolveSymbolConstraint(item.cp);
54591
- const constraint = nerdConstraint ?? (colorGlyph ? DEFAULT_EMOJI_CONSTRAINT : DEFAULT_SYMBOL_CONSTRAINT);
54905
+ const defaultConstraint = isAppleSymbolsFont(entry) ? DEFAULT_APPLE_SYMBOLS_CONSTRAINT : DEFAULT_SYMBOL_CONSTRAINT;
54906
+ const constraint = nerdConstraint ?? (colorGlyph ? DEFAULT_EMOJI_CONSTRAINT : defaultConstraint);
54592
54907
  const rowY = item.baseY - yPad - baselineOffset;
54593
54908
  const constraintWidth = Math.max(1, item.constraintWidth ?? Math.round(maxWidth / cellW));
54594
54909
  const adjusted = constrainGlyphBox({
@@ -54625,17 +54940,24 @@ function createResttyApp(options) {
54625
54940
  const glyphData = useNearest ? glyphDataNearest : glyphDataLinear;
54626
54941
  const italic = !!item.italic;
54627
54942
  const bold = !!item.bold;
54628
- const slant = italic && !colorGlyph ? gh * ITALIC_SLANT : 0;
54629
- const boldOffset = bold && !colorGlyph ? Math.max(1, Math.round(gw * BOLD_OFFSET)) : 0;
54943
+ const syntheticItalic = italic && !fontEntryHasItalicStyle(entry);
54944
+ const syntheticBold = bold && !fontEntryHasBoldStyle(entry);
54945
+ const slant = syntheticItalic && !colorGlyph ? gh * ITALIC_SLANT : 0;
54946
+ const boldOffset = syntheticBold && !colorGlyph ? Math.max(1, Math.round(gw * BOLD_OFFSET)) : 0;
54630
54947
  const renderMode = colorGlyph ? GLYPH_RENDER_MODE_COLOR : GLYPH_RENDER_MODE_MONO;
54631
54948
  const pushGlyph = (xPos) => {
54632
54949
  glyphData.push(xPos, py, gw, gh, u02, v02, u12, v12, item.fg[0], item.fg[1], item.fg[2], item.fg[3], bg[0], bg[1], bg[2], bg[3], slant, renderMode);
54633
54950
  };
54634
54951
  pushGlyph(px);
54635
54952
  if (boldOffset > 0) {
54636
- const maxX2 = item.x + maxWidth;
54637
- const bx = Math.min(px + boldOffset, Math.round(maxX2 - gw));
54638
- if (bx !== px)
54953
+ const minGlyphX = Math.round(item.x);
54954
+ const maxGlyphX = Math.round(item.x + maxWidth - gw);
54955
+ let bx = clamp(px + boldOffset, minGlyphX, maxGlyphX);
54956
+ if (bx === px)
54957
+ bx = clamp(px - boldOffset, minGlyphX, maxGlyphX);
54958
+ if (bx === px)
54959
+ pushGlyph(px);
54960
+ else
54639
54961
  pushGlyph(bx);
54640
54962
  }
54641
54963
  penX += glyph.xAdvance;
@@ -55143,10 +55465,13 @@ function createResttyApp(options) {
55143
55465
  }
55144
55466
  let nextSeqIdx = idx + baseSpan;
55145
55467
  let guard = 0;
55146
- while ((text.codePointAt(text.length - 1) ?? 0) === 8205 && nextSeqIdx < rowEnd && guard < 8) {
55468
+ while (nextSeqIdx < rowEnd && guard < 12) {
55147
55469
  const next = readCellCluster(nextSeqIdx);
55148
55470
  if (!next || !next.cp || isSpaceCp(next.cp))
55149
55471
  break;
55472
+ const shouldMerge = text.endsWith("‍") || shouldMergeTrailingClusterCodepoint(next.cp);
55473
+ if (!shouldMerge)
55474
+ break;
55150
55475
  text += next.text;
55151
55476
  baseSpan += next.span;
55152
55477
  mergedEmojiSkip[nextSeqIdx] = 1;
@@ -55177,7 +55502,7 @@ function createResttyApp(options) {
55177
55502
  }
55178
55503
  if (extra > 0 && text.trim() === "")
55179
55504
  continue;
55180
- const fontIndex = pickFontIndexForText2(text, baseSpan);
55505
+ const fontIndex = pickFontIndexForText2(text, baseSpan, stylePreferenceFromFlags(bold, italic));
55181
55506
  const fontEntry = fontState.fonts[fontIndex] ?? fontState.fonts[0];
55182
55507
  const shaped = shapeClusterWithFont(fontEntry, text);
55183
55508
  if (!shaped.glyphs.length)
@@ -55547,7 +55872,8 @@ function createResttyApp(options) {
55547
55872
  let y = item.baseY + baselineAdjust - metrics.bearingY * bitmapScale - glyph.yOffset * itemScale;
55548
55873
  if (!glyphConstrained && symbolLike && item.cp) {
55549
55874
  const nerdConstraint = resolveSymbolConstraint(item.cp);
55550
- const constraint = nerdConstraint ?? (colorGlyph ? DEFAULT_EMOJI_CONSTRAINT : DEFAULT_SYMBOL_CONSTRAINT);
55875
+ const defaultConstraint = isAppleSymbolsFont(entry) ? DEFAULT_APPLE_SYMBOLS_CONSTRAINT : DEFAULT_SYMBOL_CONSTRAINT;
55876
+ const constraint = nerdConstraint ?? (colorGlyph ? DEFAULT_EMOJI_CONSTRAINT : defaultConstraint);
55551
55877
  const rowY = item.baseY - yPad - baselineOffset;
55552
55878
  const constraintWidth = Math.max(1, item.constraintWidth ?? Math.round(maxWidth / cellW));
55553
55879
  const adjusted = constrainGlyphBox({
@@ -55576,17 +55902,24 @@ function createResttyApp(options) {
55576
55902
  const v12 = (metrics.atlasY + metrics.height - insetY) / atlasH;
55577
55903
  const italic = !!item.italic;
55578
55904
  const bold = !!item.bold;
55579
- const slant = italic && !colorGlyph ? gh * ITALIC_SLANT : 0;
55580
- const boldOffset = bold && !colorGlyph ? Math.max(1, Math.round(gw * BOLD_OFFSET)) : 0;
55905
+ const syntheticItalic = italic && !fontEntryHasItalicStyle(entry);
55906
+ const syntheticBold = bold && !fontEntryHasBoldStyle(entry);
55907
+ const slant = syntheticItalic && !colorGlyph ? gh * ITALIC_SLANT : 0;
55908
+ const boldOffset = syntheticBold && !colorGlyph ? Math.max(1, Math.round(gw * BOLD_OFFSET)) : 0;
55581
55909
  const renderMode = colorGlyph ? GLYPH_RENDER_MODE_COLOR : GLYPH_RENDER_MODE_MONO;
55582
55910
  const pushGlyph = (xPos) => {
55583
55911
  glyphData.push(xPos, py, gw, gh, u02, v02, u12, v12, item.fg[0], item.fg[1], item.fg[2], item.fg[3], bg[0], bg[1], bg[2], bg[3], slant, renderMode);
55584
55912
  };
55585
55913
  pushGlyph(px);
55586
55914
  if (boldOffset > 0) {
55587
- const maxX2 = item.x + maxWidth;
55588
- const bx = Math.min(px + boldOffset, Math.round(maxX2 - gw));
55589
- if (bx !== px)
55915
+ const minGlyphX = Math.round(item.x);
55916
+ const maxGlyphX = Math.round(item.x + maxWidth - gw);
55917
+ let bx = clamp(px + boldOffset, minGlyphX, maxGlyphX);
55918
+ if (bx === px)
55919
+ bx = clamp(px - boldOffset, minGlyphX, maxGlyphX);
55920
+ if (bx === px)
55921
+ pushGlyph(px);
55922
+ else
55590
55923
  pushGlyph(bx);
55591
55924
  }
55592
55925
  penX += glyph.xAdvance;
@@ -27,7 +27,7 @@ export declare function glyphWidthUnits(entry: FontEntry, glyphId: number | unde
27
27
  * Select the best font index from the manager's font list for rendering the
28
28
  * given text cluster, searching in fallback order similar to Ghostty.
29
29
  */
30
- export declare function pickFontIndexForText(state: FontManagerState, text: string, expectedSpan: number, shapeClusterWithFont: (entry: FontEntry, text: string) => ShapedCluster): number;
30
+ export declare function pickFontIndexForText(state: FontManagerState, text: string, expectedSpan: number): number;
31
31
  /** Fetch a font file from a URL and return its ArrayBuffer, or null on failure. */
32
32
  export declare function tryFetchFontBuffer(url: string): Promise<ArrayBuffer | null>;
33
33
  /** Query locally installed fonts via the Local Font Access API and return the first match, or null. */
package/dist/internal.js CHANGED
@@ -89,7 +89,7 @@ import {
89
89
  updateComposition,
90
90
  updateGridState,
91
91
  updateImePosition
92
- } from "./chunk-53vdvhe3.js";
92
+ } from "./chunk-ym658zhj.js";
93
93
  // src/selection/selection.ts
94
94
  function createSelectionState() {
95
95
  return {
package/dist/restty.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  isBuiltinThemeName,
8
8
  listBuiltinThemeNames,
9
9
  parseGhosttyTheme
10
- } from "./chunk-53vdvhe3.js";
10
+ } from "./chunk-ym658zhj.js";
11
11
  export {
12
12
  parseGhosttyTheme,
13
13
  listBuiltinThemeNames,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "restty",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Browser terminal rendering library powered by WASM, WebGPU/WebGL2, and TypeScript text shaping.",
5
5
  "keywords": [
6
6
  "terminal",