@promptctl/rich-js 0.18.1 → 0.19.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 (150) hide show
  1. package/README.md +9 -7
  2. package/dist/core/box.d.ts +3 -1
  3. package/dist/core/box.js +6 -13
  4. package/dist/core/cells.d.ts +61 -28
  5. package/dist/core/cells.js +171 -124
  6. package/dist/core/clock.d.ts +48 -0
  7. package/dist/core/clock.js +42 -0
  8. package/dist/core/color.d.ts +98 -18
  9. package/dist/core/color.js +331 -144
  10. package/dist/core/console.d.ts +38 -7
  11. package/dist/core/console.js +159 -90
  12. package/dist/core/easing.d.ts +86 -0
  13. package/dist/core/easing.js +171 -0
  14. package/dist/core/env.d.ts +16 -0
  15. package/dist/core/env.js +16 -0
  16. package/dist/core/export-html.js +1 -4
  17. package/dist/core/export-lines.d.ts +22 -13
  18. package/dist/core/export-lines.js +37 -19
  19. package/dist/core/export-svg.d.ts +49 -0
  20. package/dist/core/export-svg.js +213 -0
  21. package/dist/core/fnv1a.d.ts +10 -0
  22. package/dist/core/fnv1a.js +17 -0
  23. package/dist/core/highlighter.d.ts +2 -2
  24. package/dist/core/highlighter.js +113 -47
  25. package/dist/core/json.js +1 -1
  26. package/dist/core/markup.d.ts +28 -7
  27. package/dist/core/markup.js +235 -312
  28. package/dist/core/measure.d.ts +3 -3
  29. package/dist/core/measure.js +12 -7
  30. package/dist/core/memo.d.ts +19 -0
  31. package/dist/core/memo.js +34 -0
  32. package/dist/core/osc8.d.ts +6 -6
  33. package/dist/core/osc8.js +9 -21
  34. package/dist/core/paint.d.ts +107 -0
  35. package/dist/core/paint.js +150 -0
  36. package/dist/core/place.d.ts +1 -1
  37. package/dist/core/place.js +4 -6
  38. package/dist/core/pretty.d.ts +105 -2
  39. package/dist/core/pretty.js +527 -97
  40. package/dist/core/protocol.d.ts +41 -3
  41. package/dist/core/protocol.js +20 -0
  42. package/dist/core/render.d.ts +33 -30
  43. package/dist/core/render.js +43 -83
  44. package/dist/core/segment.d.ts +6 -5
  45. package/dist/core/segment.js +11 -15
  46. package/dist/core/strip.d.ts +13 -2
  47. package/dist/core/strip.js +44 -23
  48. package/dist/core/style.d.ts +27 -7
  49. package/dist/core/style.js +97 -34
  50. package/dist/core/text.d.ts +43 -16
  51. package/dist/core/text.js +180 -59
  52. package/dist/core/wrap.d.ts +5 -0
  53. package/dist/core/wrap.js +16 -3
  54. package/dist/host/app.d.ts +31 -19
  55. package/dist/host/app.js +90 -60
  56. package/dist/host/index.d.ts +2 -1
  57. package/dist/host/terminal-host.d.ts +3 -2
  58. package/dist/index.d.ts +17 -10
  59. package/dist/index.js +11 -4
  60. package/dist/node/prompt.d.ts +13 -0
  61. package/dist/node/prompt.js +30 -9
  62. package/dist/node/save.d.ts +4 -3
  63. package/dist/node/save.js +6 -3
  64. package/dist/node/terminal-host.d.ts +3 -2
  65. package/dist/node/traceback.d.ts +2 -1
  66. package/dist/node/traceback.js +3 -39
  67. package/dist/renderables/align.js +2 -9
  68. package/dist/renderables/columns.d.ts +45 -29
  69. package/dist/renderables/columns.js +142 -103
  70. package/dist/renderables/constrain.js +6 -12
  71. package/dist/renderables/effect.d.ts +98 -0
  72. package/dist/renderables/effect.js +160 -0
  73. package/dist/renderables/embed.d.ts +87 -30
  74. package/dist/renderables/embed.js +134 -45
  75. package/dist/renderables/group.d.ts +6 -3
  76. package/dist/renderables/group.js +5 -0
  77. package/dist/renderables/layout.d.ts +6 -23
  78. package/dist/renderables/layout.js +24 -113
  79. package/dist/renderables/live.d.ts +40 -16
  80. package/dist/renderables/live.js +96 -83
  81. package/dist/renderables/markdown.d.ts +16 -2
  82. package/dist/renderables/markdown.js +521 -118
  83. package/dist/renderables/padding.d.ts +22 -1
  84. package/dist/renderables/padding.js +30 -24
  85. package/dist/renderables/panel.d.ts +31 -33
  86. package/dist/renderables/panel.js +137 -141
  87. package/dist/renderables/progress.d.ts +104 -16
  88. package/dist/renderables/progress.js +205 -81
  89. package/dist/renderables/progressBar.d.ts +2 -2
  90. package/dist/renderables/progressBar.js +30 -19
  91. package/dist/renderables/prompt.d.ts +37 -15
  92. package/dist/renderables/prompt.js +125 -85
  93. package/dist/renderables/ratio.d.ts +123 -0
  94. package/dist/renderables/ratio.js +213 -0
  95. package/dist/renderables/rule.d.ts +1 -0
  96. package/dist/renderables/rule.js +44 -48
  97. package/dist/renderables/spinner.d.ts +28 -2
  98. package/dist/renderables/spinner.js +52 -19
  99. package/dist/renderables/status.d.ts +13 -7
  100. package/dist/renderables/status.js +15 -29
  101. package/dist/renderables/syntax.d.ts +37 -7
  102. package/dist/renderables/syntax.js +134 -83
  103. package/dist/renderables/table.d.ts +58 -20
  104. package/dist/renderables/table.js +370 -192
  105. package/dist/renderables/traceback.d.ts +5 -4
  106. package/dist/renderables/traceback.js +239 -68
  107. package/dist/renderables/tree.d.ts +10 -2
  108. package/dist/renderables/tree.js +61 -34
  109. package/dist/renderables/viewport.js +2 -4
  110. package/dist/template-bindings/color-funcs.d.ts +31 -9
  111. package/dist/template-bindings/color-funcs.js +40 -14
  112. package/dist/template-bindings/helpers.d.ts +4 -2
  113. package/dist/template-bindings/helpers.js +5 -20
  114. package/dist/template-bindings/index.d.ts +29 -17
  115. package/dist/template-bindings/index.js +36 -20
  116. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  117. package/dist/template-bindings/palette-funcs.js +11 -7
  118. package/dist/template-bindings/style-funcs.js +4 -2
  119. package/dist/themes/buildPalette.d.ts +24 -3
  120. package/dist/themes/buildPalette.js +52 -8
  121. package/dist/themes/colorMath.d.ts +29 -17
  122. package/dist/themes/colorMath.js +74 -48
  123. package/dist/themes/colorRef.d.ts +2 -17
  124. package/dist/themes/colorRef.js +13 -31
  125. package/dist/themes/palette.d.ts +24 -0
  126. package/dist/themes/palette.js +46 -3
  127. package/dist/themes/ramp.d.ts +21 -34
  128. package/dist/themes/ramp.js +23 -42
  129. package/dist/themes/registry.js +39 -28
  130. package/dist/widgets/button.d.ts +2 -6
  131. package/dist/widgets/button.js +9 -40
  132. package/dist/widgets/checkbox.d.ts +2 -5
  133. package/dist/widgets/checkbox.js +9 -31
  134. package/dist/widgets/dropdown.d.ts +2 -5
  135. package/dist/widgets/dropdown.js +14 -48
  136. package/dist/widgets/ink.d.ts +22 -0
  137. package/dist/widgets/ink.js +31 -0
  138. package/dist/widgets/slider.d.ts +2 -5
  139. package/dist/widgets/slider.js +11 -34
  140. package/dist/widgets/text-input.d.ts +19 -10
  141. package/dist/widgets/text-input.js +156 -147
  142. package/dist/widgets/themed-widget.d.ts +20 -0
  143. package/dist/widgets/themed-widget.js +89 -0
  144. package/dist/widgets/toggle.d.ts +2 -5
  145. package/dist/widgets/toggle.js +8 -31
  146. package/dist/widgets/widget-app.d.ts +2 -0
  147. package/dist/widgets/widget-app.js +13 -6
  148. package/dist/widgets/widget-base.d.ts +2 -0
  149. package/dist/widgets/widget-base.js +9 -2
  150. package/package.json +11 -1
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { Segment } from "../core/segment.js";
5
5
  import { Style } from "../core/style.js";
6
+ import type { RichText } from "../core/text.js";
6
7
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
7
8
  export type PaddingDimensions = number | [number, number] | [number, number, number, number];
8
9
  /**
@@ -18,6 +19,25 @@ export type PaddingDimensions = number | [number, number] | [number, number, num
18
19
  * border and `Table` threw outright.
19
20
  */
20
21
  export declare function normalizePadding(padding: PaddingDimensions): [number, number, number, number];
22
+ /** The three spans of a padded row, summing to exactly the requested width. */
23
+ interface PaddingGeometry {
24
+ readonly left: number;
25
+ readonly contentWidth: number;
26
+ readonly right: number;
27
+ }
28
+ /**
29
+ * Divides the requested width once into the three spans a padded row is made
30
+ * of, in the order they are worth spending cells on.
31
+ *
32
+ * The three used to be derived separately and disagree where it matters:
33
+ * `render` took the canvas as `Math.max(1, maxWidth - left - right)`, which
34
+ * clamps *up*, so a padding of 1 inside a 2-cell request drew 3-cell content
35
+ * rows beside 2-cell blank rows — an open frame the terminal then soft-wraps.
36
+ * Content takes its first cell before padding takes any, for the reason
37
+ * `layoutPanel` gives: a squeeze should cost you the decoration, not the thing
38
+ * being decorated.
39
+ */
40
+ export declare function layoutPadding(outerWidth: number, leftWanted: number, rightWanted: number): PaddingGeometry;
21
41
  export declare class Padding implements Renderable, Measurable {
22
42
  readonly renderable: Renderable;
23
43
  readonly top: number;
@@ -26,7 +46,7 @@ export declare class Padding implements Renderable, Measurable {
26
46
  readonly left: number;
27
47
  readonly style: string | Style;
28
48
  readonly expand: boolean;
29
- constructor(renderable: Renderable, padding: PaddingDimensions, options?: {
49
+ constructor(content: string | RichText | Renderable, padding: PaddingDimensions, options?: {
30
50
  style?: string | Style;
31
51
  expand?: boolean;
32
52
  });
@@ -36,3 +56,4 @@ export declare class Padding implements Renderable, Measurable {
36
56
  maximum: number;
37
57
  };
38
58
  }
59
+ export {};
@@ -5,7 +5,8 @@ import { Segment } from "../core/segment.js";
5
5
  import { cellCount } from "../core/cells.js";
6
6
  import { NULL_STYLE } from "../core/style.js";
7
7
  import { Measurement } from "../core/measure.js";
8
- import { fitHeight, getStyle, insetHeight, isMeasurable, withBoundedWidth, withCellWidth } from "../core/protocol.js";
8
+ import { fitHeight, getStyle, insetHeight, withBoundedWidth, withCellWidth } from "../core/protocol.js";
9
+ import { embed } from "./embed.js";
9
10
  /**
10
11
  * Parses the shapes a caller may write padding in — one number, a vertical/
11
12
  * horizontal pair, or all four sides — into the four-sided form every
@@ -51,7 +52,7 @@ export function normalizePadding(padding) {
51
52
  * `layoutPanel` gives: a squeeze should cost you the decoration, not the thing
52
53
  * being decorated.
53
54
  */
54
- function layoutPadding(outerWidth, leftWanted, rightWanted) {
55
+ export function layoutPadding(outerWidth, leftWanted, rightWanted) {
55
56
  let budget = cellCount(outerWidth);
56
57
  const take = (want) => {
57
58
  const got = Math.min(want, budget);
@@ -75,9 +76,9 @@ export class Padding {
75
76
  left;
76
77
  style;
77
78
  expand;
78
- constructor(renderable, padding, options) {
79
+ constructor(content, padding, options) {
79
80
  const [top, right, bottom, left] = normalizePadding(padding);
80
- this.renderable = renderable;
81
+ this.renderable = embed(content);
81
82
  this.top = top;
82
83
  this.right = right;
83
84
  this.bottom = bottom;
@@ -87,16 +88,25 @@ export class Padding {
87
88
  }
88
89
  *render(rawOptions) {
89
90
  const options = withBoundedWidth(rawOptions, this);
90
- const geometry = layoutPadding(options.maxWidth, this.left, this.right);
91
+ // `expand` decides how wide the block is, never whether its rows reach
92
+ // that width: as Rich's does, a padding that does not expand narrows to
93
+ // what it measures, and every row is then padded out to it. Deciding the
94
+ // rows instead left the content rows ragged under a full-width blank row,
95
+ // and a `style` ground with holes down its right-hand side.
96
+ const width = this.expand ? options.maxWidth : Measurement.get(options, this).maximum;
97
+ const geometry = layoutPadding(width, this.left, this.right);
91
98
  const innerOptions = {
92
99
  ...options,
93
100
  maxWidth: geometry.contentWidth,
94
101
  height: insetHeight(options.height, this.top + this.bottom),
95
102
  };
96
- const segments = [...this.renderable.render(innerOptions)];
97
- const lines = fitHeight(Segment.splitLines(segments), innerOptions.height);
98
103
  const resolved = getStyle(options, this.style);
99
104
  const style = resolved.isNull ? undefined : resolved;
105
+ // The style is the ground the content is drawn on, not only the spaces
106
+ // around it — Rich's `render_lines(..., style=style)`. Applied to the
107
+ // padding alone, a background left a hole behind the content.
108
+ const segments = [...Segment.applyStyle(this.renderable.render(innerOptions), style)];
109
+ const lines = fitHeight(Segment.splitLines(segments), innerOptions.height);
100
110
  // Zero-length spans need no branch to suppress: the wire boundary drops
101
111
  // empty segments, so a padding the width could not afford emits nothing.
102
112
  const leftPad = new Segment(" ".repeat(geometry.left), style);
@@ -108,10 +118,9 @@ export class Padding {
108
118
  }
109
119
  for (const line of lines) {
110
120
  yield leftPad;
111
- // `expand` is the pad half of this call; the crop half is unconditional,
112
- // because a child that ignored the canvas it was handed (a Table at its
113
- // natural width) would otherwise burst the padding around it.
114
- yield* Segment.adjustLineLength(line, geometry.contentWidth, style, this.expand);
121
+ // Crops as well as pads: a child that ignored the canvas it was handed
122
+ // (a Table at its natural width) would otherwise burst the padding.
123
+ yield* Segment.adjustLineLength(line, geometry.contentWidth, style);
115
124
  yield rightPad;
116
125
  yield Segment.line();
117
126
  }
@@ -124,18 +133,15 @@ export class Padding {
124
133
  const options = withCellWidth(rawOptions);
125
134
  const geometry = layoutPadding(options.maxWidth, this.left, this.right);
126
135
  const overhead = geometry.left + geometry.right;
127
- if (isMeasurable(this.renderable)) {
128
- const measurement = Measurement.get({ ...options, maxWidth: geometry.contentWidth }, this.renderable);
129
- // The ceiling is what was offered, and it wins over what the child asks
130
- // for: a range whose floor sits above its own ceiling — this returned
131
- // {8, 8} for three cells of padding inside a two-cell offer — is one no
132
- // parent layout can divide.
133
- const maximum = Math.min(options.maxWidth, measurement.maximum + overhead);
134
- return {
135
- minimum: Math.min(measurement.minimum + overhead, maximum),
136
- maximum,
137
- };
138
- }
139
- return { minimum: overhead, maximum: options.maxWidth };
136
+ const measurement = Measurement.get({ ...options, maxWidth: geometry.contentWidth }, this.renderable);
137
+ // The ceiling is what was offered, and it wins over what the child asks
138
+ // for: a range whose floor sits above its own ceiling — this returned
139
+ // {8, 8} for three cells of padding inside a two-cell offer — is one no
140
+ // parent layout can divide.
141
+ const maximum = Math.min(options.maxWidth, measurement.maximum + overhead);
142
+ return {
143
+ minimum: Math.min(measurement.minimum + overhead, maximum),
144
+ maximum,
145
+ };
140
146
  }
141
147
  }
@@ -9,8 +9,8 @@ import type { PaddingDimensions } from "./padding.js";
9
9
  import type { Renderable, Measurable, RenderOptions } from "../core/protocol.js";
10
10
  /**
11
11
  * A lazily-resolved border accessory. Strings render inline in the
12
- * border style; a `RichText` accessory contributes ONLY its wrapping
13
- * `style` (per-range spans within the RichText are not preserved — the
12
+ * border style; a `RichText` accessory lays ONLY its wrapping `style` over
13
+ * the border's (per-range spans within the RichText are not preserved — the
14
14
  * accessory is a small status indicator, not an arbitrary span carrier).
15
15
  * A function form is evaluated at render time, *after* content has been
16
16
  * rendered for the current frame — use this when the accessory mirrors
@@ -34,14 +34,13 @@ export interface PanelOptions {
34
34
  style?: string | Style;
35
35
  borderStyle?: string | Style;
36
36
  /**
37
- * Style for the title text in the top border. Defaults to `borderStyle`
38
- * (i.e. title inherits the border color). Set independently when the
39
- * title should pop relative to the border.
37
+ * Style for the title text in the top border, laid over `borderStyle`:
38
+ * what it sets wins, and what it leaves unset the border's supplies.
40
39
  */
41
40
  titleStyle?: string | Style;
42
41
  /**
43
- * Style for the subtitle text in the bottom border. Defaults to
44
- * `borderStyle`.
42
+ * Style for the subtitle text in the bottom border, laid over
43
+ * `borderStyle` as `titleStyle` is.
45
44
  */
46
45
  subtitleStyle?: string | Style;
47
46
  width?: number;
@@ -60,6 +59,8 @@ export declare class Panel implements Renderable, Measurable {
60
59
  readonly subtitleStyle: string | Style | undefined;
61
60
  readonly width: number | undefined;
62
61
  readonly padding: [number, number, number, number];
62
+ private readonly _titleLabel;
63
+ private readonly _subtitleLabel;
63
64
  constructor(content: string | RichText | Renderable, options?: PanelOptions);
64
65
  render(rawOptions: RenderOptions): Iterable<Segment>;
65
66
  /**
@@ -70,44 +71,41 @@ export declare class Panel implements Renderable, Measurable {
70
71
  */
71
72
  private _renderContent;
72
73
  /**
73
- * One row of the panel body: frame column, span, frame column.
74
+ * One row of the panel body, cut where Rich cuts it: frame column, left
75
+ * padding, the content line made exactly `contentWidth` wide, right padding,
76
+ * frame column — each its own segment, so each its own SGR run.
74
77
  *
75
- * [LAW:single-enforcer] `adjustLineLength` is the one place a width is
76
- * decided here, and it runs twice against two different widths. The first
77
- * pass crops content that rendered wider than the canvas it was handed (a
78
- * `Table` at its natural width, say) back to the canvas, so an oversized
79
- * child is trimmed rather than allowed to eat the right-hand padding. The
80
- * second fills the rest of the span, which is the trailing padding and any
81
- * shortfall in one stroke.
78
+ * [LAW:single-enforcer] `adjustLineLength` is the one place the content's
79
+ * width is decided: content that rendered wider than its canvas (a `Table`
80
+ * at its natural width, say) is cropped back to it, and a short line is
81
+ * filled out to it, so the right padding always starts at the same column.
82
82
  */
83
83
  private _renderRow;
84
+ /**
85
+ * A row of vertical padding: one blank run across the whole span, as Rich's
86
+ * `Padding` draws its blank lines, not a content row with nothing in it.
87
+ */
88
+ private _renderPaddingRow;
84
89
  measure(rawOptions: RenderOptions): {
85
90
  minimum: number;
86
91
  maximum: number;
87
92
  };
88
- /**
89
- * The width this panel wants when nothing declared one for it: its content
90
- * plus its own frame, both read off the division it will render against.
91
- *
92
- * [LAW:one-source-of-truth] `measure` and `_getPanelWidth` ask this same
93
- * question, and each used to work it out itself — the same `layoutPanel`, the
94
- * same `frameOverhead`, the same `Math.min(maxWidth, maximum + overhead)`,
95
- * written twice. That is the pattern `_declaredWidth` below was extracted to
96
- * stop, left standing for the fit case; changing how overhead is derived in
97
- * one copy is all it would take to put `measure` and `render` back into the
98
- * disagreement this epic spent itself closing.
99
- */
100
- private _fitRange;
93
+ /** The content plus its own frame, both read off the division it will render against. */
94
+ private _contentRange;
95
+ /** The cells the title's label takes, its padding included; none without a title. */
96
+ private _labelWidth;
101
97
  /**
102
98
  * The width this panel was told to be, as a count of cells.
103
99
  *
104
- * [LAW:one-source-of-truth] `measure` and `_getPanelWidth` answer the same
105
- * question about the same field, so they read it from here. Answered
106
- * separately, `measure` reported nine cells of content while `render` drew the
107
- * declared twelve, and the parent that divided space from the range got a
108
- * panel three cells wider than the share it granted.
100
+ * [LAW:one-source-of-truth] `measure` and `_getPanelWidth` read the field
101
+ * from here, so neither can count it differently from the other.
109
102
  */
110
103
  private get _declaredWidth();
104
+ /**
105
+ * As Rich's `__rich_console__` sizes it: a declared width is a ceiling that
106
+ * `expand` fills and a fitted panel fits its content inside, and the title
107
+ * widens either.
108
+ */
111
109
  private _getPanelWidth;
112
110
  private _renderTopBorder;
113
111
  private _renderBottomBorder;
@@ -7,9 +7,9 @@ import { NULL_STYLE } from "../core/style.js";
7
7
  import { ROUNDED } from "../core/box.js";
8
8
  import { Measurement } from "../core/measure.js";
9
9
  import { RichText } from "../core/text.js";
10
- import { embed, inlineLabel } from "./embed.js";
10
+ import { cutLabel, drawLabel, embed, inlineLabel } from "./embed.js";
11
11
  import { normalizePadding } from "./padding.js";
12
- import { fitHeight, getStyle, insetHeight, isMeasurable, withBoundedWidth, withCellWidth } from "../core/protocol.js";
12
+ import { drawable, fitHeight, getStyle, insetHeight, withBoundedWidth, withCellWidth } from "../core/protocol.js";
13
13
  function layoutPanel(outerWidth, padding) {
14
14
  const [, padRightWanted, , padLeftWanted] = padding;
15
15
  // Panel's one division point, and the width reaching it is not always one
@@ -49,11 +49,58 @@ function frameOverhead(geometry) {
49
49
  return geometry.left + geometry.right + geometry.padLeft + geometry.padRight;
50
50
  }
51
51
  /**
52
- * The style of text set into a border: its own when one was given, the
53
- * border's otherwise — the one rule for "what colour is the title text in".
52
+ * How a title or subtitle string is read: always as markup and never
53
+ * highlighted, whatever the console says, because Rich's `Panel` reads its
54
+ * title with `Text.from_markup` rather than through the console.
54
55
  */
55
- function borderTextStyle(options, own, border) {
56
- return own === undefined ? border : getStyle(options, own);
56
+ function borderLabel(options) {
57
+ return { ...options, markup: true, highlight: false };
58
+ }
59
+ /** A title or subtitle as Rich's `Panel._title` sets it: read as a border reads it, a space either side. */
60
+ function borderTitle(label, options) {
61
+ return label.text(borderLabel(options)).pad(1);
62
+ }
63
+ /**
64
+ * The style of text set into a border — title, subtitle and accessory alike:
65
+ * its own laid over the border's, the one rule for "what colour is the title
66
+ * text in". Laid over rather than in place of it, as Rich's `stylize_before`
67
+ * does, because the border's style carries the panel's own: a title that
68
+ * replaced it cut a hole in the panel's background on the top row.
69
+ */
70
+ function borderTextStyle(border, own) {
71
+ return own.isNull ? border : (border ?? NULL_STYLE).add(own);
72
+ }
73
+ /**
74
+ * A border row: the opening corner, the rule across `span` cells with `label`
75
+ * centred in it, and `close` — the closing corner, or nothing when the caller
76
+ * sets something between the rule and its corner.
77
+ *
78
+ * Cut as Rich cuts it. An unlabelled rule is one run, corners included. A
79
+ * label is set into the `span - 2` cells between one rule cell either side,
80
+ * truncated to them as Rich's `align_text` truncates it — marked only when the
81
+ * label's own overflow is `"ellipsis"` — and centred with the rule filling the
82
+ * rest, so the row leaves as corner-and-rule, rule, label, rule,
83
+ * rule-and-corner. Rich draws no label in a span of two cells or fewer
84
+ * (`width <= 4`), where it would sit on the corners.
85
+ */
86
+ function borderRow(options, edge, geometry, span, label, labelStyle, border, close) {
87
+ const corner = edge.left.repeat(geometry.left);
88
+ if (label === undefined || span <= 2) {
89
+ return [new Segment(corner + edge.horizontal.repeat(span) + close, border)];
90
+ }
91
+ const canvas = span - 2;
92
+ const text = borderTitle(label, options);
93
+ cutLabel(text, canvas, text.overflow === "ellipsis" ? drawable(options, "\u2026", ".") : "");
94
+ const fitted = drawLabel(text, borderLabel(options), labelStyle);
95
+ const excess = canvas - Segment.getLineLength(fitted);
96
+ const before = Math.floor(excess / 2);
97
+ return [
98
+ new Segment(corner + edge.horizontal, border),
99
+ new Segment(edge.horizontal.repeat(before), border),
100
+ ...fitted,
101
+ new Segment(edge.horizontal.repeat(excess - before), border),
102
+ new Segment(edge.horizontal + close, border),
103
+ ];
57
104
  }
58
105
  export class Panel {
59
106
  renderable;
@@ -68,11 +115,15 @@ export class Panel {
68
115
  subtitleStyle;
69
116
  width;
70
117
  padding;
118
+ _titleLabel;
119
+ _subtitleLabel;
71
120
  constructor(content, options) {
72
121
  this.renderable = embed(content);
73
122
  this.box = options?.box ?? ROUNDED;
74
123
  this.title = options?.title;
75
124
  this.subtitle = options?.subtitle;
125
+ this._titleLabel = inlineLabel(this.title);
126
+ this._subtitleLabel = inlineLabel(this.subtitle);
76
127
  this.bottomRightAccessory = options?.bottomRightAccessory;
77
128
  this.expand = options?.expand !== false;
78
129
  this.style = options?.style ?? NULL_STYLE;
@@ -85,26 +136,27 @@ export class Panel {
85
136
  *render(rawOptions) {
86
137
  const options = withBoundedWidth(rawOptions, this);
87
138
  const box = this.box.substitute(options);
88
- const borderStyle = getStyle(options, this.borderStyle);
139
+ // [LAW:one-source-of-truth] `style` is the ground the whole panel is drawn
140
+ // on, as Rich's is: the frame's style is the border style laid over it,
141
+ // and the content is rendered on top of it. Applied to the padding alone,
142
+ // "white on dark_blue" drew a blue ring with a hole where the text was.
89
143
  const style = getStyle(options, this.style);
144
+ const borderStyle = style.add(getStyle(options, this.borderStyle));
90
145
  const border = borderStyle.isNull ? undefined : borderStyle;
91
146
  const contentStyle = style.isNull ? undefined : style;
92
147
  const geometry = layoutPanel(this._getPanelWidth(options), this.padding);
93
148
  const [padTop, , padBottom] = this.padding;
94
- const contentLines = this._renderContent(options, geometry.contentWidth);
95
- // Top border (with optional title)
149
+ const contentLines = this._renderContent(options, geometry.contentWidth, contentStyle);
96
150
  yield* this._renderTopBorder(options, box, geometry, border);
97
- // Top padding — a padding row is a content row whose content is nothing.
98
151
  for (let i = 0; i < padTop; i++) {
99
- yield* this._renderRow(box, geometry, [], border, contentStyle);
152
+ yield* this._renderPaddingRow(box, geometry, border, contentStyle);
100
153
  }
101
154
  for (const line of contentLines) {
102
155
  yield* this._renderRow(box, geometry, line, border, contentStyle);
103
156
  }
104
157
  for (let i = 0; i < padBottom; i++) {
105
- yield* this._renderRow(box, geometry, [], border, contentStyle);
158
+ yield* this._renderPaddingRow(box, geometry, border, contentStyle);
106
159
  }
107
- // Bottom border (with optional subtitle)
108
160
  yield* this._renderBottomBorder(options, box, geometry, border);
109
161
  }
110
162
  /**
@@ -113,137 +165,107 @@ export class Panel {
113
165
  * but the render still happens, because a `bottomRightAccessory` thunk
114
166
  * fires at every width and reads state this render populates.
115
167
  */
116
- _renderContent(options, contentWidth) {
168
+ _renderContent(options, contentWidth, style) {
117
169
  // Two border rows and the vertical padding are the panel's own. Handed on
118
170
  // as a region, the rest is the panel's to shape, which is what stretches
119
171
  // its frame down a pane and keeps its bottom border inside one.
120
172
  const [padTop, , padBottom] = this.padding;
121
173
  const height = insetHeight(options.height, 2 + padTop + padBottom);
122
- const innerOptions = { ...options, maxWidth: contentWidth, height };
123
- const lines = fitHeight(Segment.splitLines([...this.renderable.render(innerOptions)]), height);
174
+ // No highlighter: what the panel holds is drawn plain, as Rich's `Panel`
175
+ // hands its body `highlight=False`.
176
+ const innerOptions = { ...options, highlight: false, maxWidth: contentWidth, height };
177
+ const lines = fitHeight(Segment.splitLines([...Segment.applyStyle(this.renderable.render(innerOptions), style)]), height);
124
178
  return contentWidth === 0 ? [] : lines;
125
179
  }
126
180
  /**
127
- * One row of the panel body: frame column, span, frame column.
181
+ * One row of the panel body, cut where Rich cuts it: frame column, left
182
+ * padding, the content line made exactly `contentWidth` wide, right padding,
183
+ * frame column — each its own segment, so each its own SGR run.
128
184
  *
129
- * [LAW:single-enforcer] `adjustLineLength` is the one place a width is
130
- * decided here, and it runs twice against two different widths. The first
131
- * pass crops content that rendered wider than the canvas it was handed (a
132
- * `Table` at its natural width, say) back to the canvas, so an oversized
133
- * child is trimmed rather than allowed to eat the right-hand padding. The
134
- * second fills the rest of the span, which is the trailing padding and any
135
- * shortfall in one stroke.
185
+ * [LAW:single-enforcer] `adjustLineLength` is the one place the content's
186
+ * width is decided: content that rendered wider than its canvas (a `Table`
187
+ * at its natural width, say) is cropped back to it, and a short line is
188
+ * filled out to it, so the right padding always starts at the same column.
136
189
  */
137
190
  *_renderRow(box, geometry, line, border, contentStyle) {
138
191
  const frame = box.getContentChars("row");
139
192
  yield new Segment(frame.left.repeat(geometry.left), border);
140
- const content = Segment.adjustLineLength(line, geometry.contentWidth, contentStyle, false);
141
- const span = [new Segment(" ".repeat(geometry.padLeft), contentStyle), ...content];
142
- yield* Segment.adjustLineLength(span, geometry.spanWidth, contentStyle);
193
+ yield new Segment(" ".repeat(geometry.padLeft), contentStyle);
194
+ yield* Segment.adjustLineLength(line, geometry.contentWidth, contentStyle);
195
+ yield new Segment(" ".repeat(geometry.padRight), contentStyle);
196
+ yield new Segment(frame.right.repeat(geometry.right), border);
197
+ yield Segment.line();
198
+ }
199
+ /**
200
+ * A row of vertical padding: one blank run across the whole span, as Rich's
201
+ * `Padding` draws its blank lines, not a content row with nothing in it.
202
+ */
203
+ *_renderPaddingRow(box, geometry, border, contentStyle) {
204
+ const frame = box.getContentChars("row");
205
+ yield new Segment(frame.left.repeat(geometry.left), border);
206
+ yield new Segment(" ".repeat(geometry.spanWidth), contentStyle);
143
207
  yield new Segment(frame.right.repeat(geometry.right), border);
144
208
  yield Segment.line();
145
209
  }
146
210
  measure(rawOptions) {
147
211
  const options = withCellWidth(rawOptions);
212
+ // Rich's `__rich_measure__`, which is not its render's arithmetic: a
213
+ // declared width is the measurement whatever the panel then draws inside
214
+ // it, and a title counts as content beside the body, with the padding and
215
+ // frame around it, where the render holds it between one rule cell either
216
+ // side. A parent sizing from it sizes as Rich's parent does.
148
217
  const declared = this._declaredWidth;
149
218
  if (declared !== undefined) {
150
- // A declared width is not a ceiling on the content, it is the answer:
151
- // `_getPanelWidth` returns it before measuring anything, so the panel
152
- // draws at this width whatever the content wants. Reported with a
153
- // content-derived floor instead, `{width: 20}` around "hi" offered 100
154
- // cells answered 6..20 and then drew 20 every time, and a parent
155
- // dividing space from the floor under-provisioned it.
156
219
  const width = Math.min(options.maxWidth, declared);
157
220
  return { minimum: width, maximum: width };
158
221
  }
159
- return this._fitRange(options);
222
+ const content = this._contentRange(options);
223
+ const overhead = frameOverhead(layoutPanel(options.maxWidth, this.padding));
224
+ const maximum = Math.min(options.maxWidth, Math.max(content.maximum, this._labelWidth(options) + overhead));
225
+ return { minimum: Math.min(content.minimum, maximum), maximum };
160
226
  }
161
- /**
162
- * The width this panel wants when nothing declared one for it: its content
163
- * plus its own frame, both read off the division it will render against.
164
- *
165
- * [LAW:one-source-of-truth] `measure` and `_getPanelWidth` ask this same
166
- * question, and each used to work it out itself — the same `layoutPanel`, the
167
- * same `frameOverhead`, the same `Math.min(maxWidth, maximum + overhead)`,
168
- * written twice. That is the pattern `_declaredWidth` below was extracted to
169
- * stop, left standing for the fit case; changing how overhead is derived in
170
- * one copy is all it would take to put `measure` and `render` back into the
171
- * disagreement this epic spent itself closing.
172
- */
173
- _fitRange(options) {
227
+ /** The content plus its own frame, both read off the division it will render against. */
228
+ _contentRange(options) {
174
229
  const geometry = layoutPanel(options.maxWidth, this.padding);
175
230
  const overhead = frameOverhead(geometry);
176
- if (isMeasurable(this.renderable)) {
177
- const innerOptions = {
178
- ...options,
179
- maxWidth: geometry.contentWidth,
180
- };
181
- const measurement = Measurement.get(innerOptions, this.renderable);
182
- const maximum = Math.min(options.maxWidth, measurement.maximum + overhead);
183
- return {
184
- minimum: Math.min(measurement.minimum + overhead, maximum),
185
- maximum,
186
- };
187
- }
188
- // Content that cannot measure itself leaves the panel with nothing to want,
189
- // so it wants the offer — unbounded included, which `withBoundedWidth`
190
- // reports rather than turning into a number nobody can defend.
191
- return { minimum: Math.min(overhead, options.maxWidth), maximum: options.maxWidth };
231
+ const measurement = Measurement.get({ ...options, maxWidth: geometry.contentWidth }, this.renderable);
232
+ return {
233
+ minimum: measurement.minimum + overhead,
234
+ maximum: Math.min(options.maxWidth, measurement.maximum + overhead),
235
+ };
236
+ }
237
+ /** The cells the title's label takes, its padding included; none without a title. */
238
+ _labelWidth(options) {
239
+ return this._titleLabel === undefined ? 0 : borderTitle(this._titleLabel, options).cellLength;
192
240
  }
193
241
  /**
194
242
  * The width this panel was told to be, as a count of cells.
195
243
  *
196
- * [LAW:one-source-of-truth] `measure` and `_getPanelWidth` answer the same
197
- * question about the same field, so they read it from here. Answered
198
- * separately, `measure` reported nine cells of content while `render` drew the
199
- * declared twelve, and the parent that divided space from the range got a
200
- * panel three cells wider than the share it granted.
244
+ * [LAW:one-source-of-truth] `measure` and `_getPanelWidth` read the field
245
+ * from here, so neither can count it differently from the other.
201
246
  */
202
247
  get _declaredWidth() {
203
248
  return this.width === undefined ? undefined : cellCount(this.width);
204
249
  }
250
+ /**
251
+ * As Rich's `__rich_console__` sizes it: a declared width is a ceiling that
252
+ * `expand` fills and a fitted panel fits its content inside, and the title
253
+ * widens either.
254
+ */
205
255
  _getPanelWidth(options) {
206
- const declared = this._declaredWidth;
207
- if (declared !== undefined)
208
- return Math.min(declared, options.maxWidth);
209
- if (this.expand)
210
- return options.maxWidth;
211
- return this._fitRange(options).maximum;
256
+ const ceiling = Math.min(options.maxWidth, this._declaredWidth ?? options.maxWidth);
257
+ const body = this.expand ? ceiling : this._contentRange({ ...options, maxWidth: ceiling }).maximum;
258
+ // The title holds its label whole between one rule cell either side —
259
+ // past a declared width too, up to the width the panel was offered.
260
+ const floor = this._titleLabel === undefined ? 0 : this._labelWidth(options) + 4;
261
+ return Math.min(options.maxWidth, Math.max(body, floor));
212
262
  }
213
263
  *_renderTopBorder(options, box, geometry, border) {
214
- const innerBorderWidth = geometry.spanWidth;
215
- const titleSeg = borderTextStyle(options, this.titleStyle, border);
216
- const title = inlineLabel(this.title, options, titleSeg);
217
- const titleWidth = Segment.getLineLength(title);
218
- if (titleWidth === 0) {
219
- yield new Segment(box.top.left.repeat(geometry.left), border);
220
- yield new Segment(box.top.horizontal.repeat(innerBorderWidth), border);
221
- yield new Segment(box.top.right.repeat(geometry.right), border);
222
- yield Segment.line();
223
- return;
224
- }
225
- yield new Segment(box.top.left.repeat(geometry.left), border);
226
- if (titleWidth >= innerBorderWidth) {
227
- // Title fills the border. [LAW:one-source-of-truth] adjustLineLength is
228
- // cell-aware — a plain .slice would miscount wide chars and break border
229
- // alignment.
230
- yield* Segment.adjustLineLength(title, innerBorderWidth, titleSeg);
231
- }
232
- else {
233
- // Center the title in the top border
234
- const leftRuleWidth = Math.floor((innerBorderWidth - titleWidth) / 2);
235
- const rightRuleWidth = innerBorderWidth - titleWidth - leftRuleWidth;
236
- if (leftRuleWidth > 0)
237
- yield new Segment(box.top.horizontal.repeat(leftRuleWidth), border);
238
- yield* title;
239
- if (rightRuleWidth > 0)
240
- yield new Segment(box.top.horizontal.repeat(rightRuleWidth), border);
241
- }
242
- yield new Segment(box.top.right.repeat(geometry.right), border);
264
+ const titleStyle = borderTextStyle(border, getStyle(options, this.titleStyle ?? NULL_STYLE));
265
+ yield* borderRow(options, box.top, geometry, geometry.spanWidth, this._titleLabel, titleStyle, border, box.top.right.repeat(geometry.right));
243
266
  yield Segment.line();
244
267
  }
245
268
  *_renderBottomBorder(options, box, geometry, border) {
246
- const innerBorderWidth = geometry.spanWidth;
247
269
  // Resolve the right accessory *now*. Function form evaluates after
248
270
  // content has been rendered (Panel.render collects content segments
249
271
  // before yielding any borders), so the thunk sees fresh widget state.
@@ -253,44 +275,18 @@ export class Panel {
253
275
  : typeof accessory === "string"
254
276
  ? ` ${accessory} `
255
277
  : ` ${accessory.plain} `;
256
- const accessoryWidth = cellLen(accessoryDisplay);
278
+ // Cell-aware clip: the accessory never claims more than the span.
279
+ const accessoryText = setCellSize(accessoryDisplay, asCellCol(Math.min(cellLen(accessoryDisplay), geometry.spanWidth)));
257
280
  const accessoryOwn = accessory instanceof RichText ? accessory.resolvedStyle(options) : NULL_STYLE;
258
- const accessoryStyle = accessoryOwn.isNull ? border : accessoryOwn;
259
- yield new Segment(box.bottom.left.repeat(geometry.left), border);
260
- // Space available for the centered subtitle / rule fill — the accessory
261
- // (if any) hugs the right edge and the subtitle treats the remainder
262
- // as its centering canvas.
263
- const centerWidth = Math.max(0, innerBorderWidth - accessoryWidth);
264
- const subtitleSeg = borderTextStyle(options, this.subtitleStyle, border);
265
- const subtitle = inlineLabel(this.subtitle, options, subtitleSeg);
266
- const subtitleWidth = Segment.getLineLength(subtitle);
267
- if (subtitleWidth === 0) {
268
- if (centerWidth > 0)
269
- yield new Segment(box.bottom.horizontal.repeat(centerWidth), border);
270
- }
271
- else {
272
- if (subtitleWidth >= centerWidth) {
273
- // Cell-aware clip — see _renderTopBorder.
274
- yield* Segment.adjustLineLength(subtitle, centerWidth, subtitleSeg);
275
- }
276
- else {
277
- const leftRuleWidth = Math.floor((centerWidth - subtitleWidth) / 2);
278
- const rightRuleWidth = centerWidth - subtitleWidth - leftRuleWidth;
279
- if (leftRuleWidth > 0)
280
- yield new Segment(box.bottom.horizontal.repeat(leftRuleWidth), border);
281
- yield* subtitle;
282
- if (rightRuleWidth > 0)
283
- yield new Segment(box.bottom.horizontal.repeat(rightRuleWidth), border);
284
- }
285
- }
286
- if (accessoryWidth > 0) {
287
- // Cell-aware clip — see _renderTopBorder.
288
- const fit = accessoryWidth > innerBorderWidth
289
- ? setCellSize(accessoryDisplay, asCellCol(innerBorderWidth))
290
- : accessoryDisplay;
291
- yield new Segment(fit, accessoryStyle);
292
- }
293
- yield new Segment(box.bottom.right.repeat(geometry.right), border);
281
+ const subtitleStyle = borderTextStyle(border, getStyle(options, this.subtitleStyle ?? NULL_STYLE));
282
+ // The accessory hugs the bottom-right corner; the rule and subtitle are
283
+ // laid out, as Rich lays them, across the span it leaves. With no
284
+ // accessory the corner closes the rule's last run, as Rich's does.
285
+ const corner = box.bottom.right.repeat(geometry.right);
286
+ const [close, afterAccessory] = accessoryText === "" ? [corner, ""] : ["", corner];
287
+ yield* borderRow(options, box.bottom, geometry, geometry.spanWidth - cellLen(accessoryText), this._subtitleLabel, subtitleStyle, border, close);
288
+ yield new Segment(accessoryText, borderTextStyle(border, accessoryOwn));
289
+ yield new Segment(afterAccessory, border);
294
290
  yield Segment.line();
295
291
  }
296
292
  _resolveAccessory(a) {