@nghyane/arcane-tui 0.1.9 → 0.1.11

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
1
  # Changelog
2
2
 
3
3
  ## [Unreleased]
4
+
5
+ ## [0.1.11] - 2026-03-02
6
+
7
+ ### Added
8
+
9
+ - `LeftBorderBox` component: renders children with a colored left border accent ([#51](https://github.com/nghyane/arcane/issues/51))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane-tui",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/nghyane/arcane",
7
7
  "author": "Can Bölük",
@@ -0,0 +1,93 @@
1
+ import type { Component } from "../tui";
2
+ import { applyBackgroundToLine, padding } from "../utils";
3
+
4
+ /**
5
+ * LeftBorderBox - a container that renders children with a colored left border accent
6
+ * and an optional full-width background.
7
+ *
8
+ * Used as a lighter alternative to full-background Box for tool outputs.
9
+ * The border character is colored via borderFn to indicate status.
10
+ */
11
+ export class LeftBorderBox implements Component {
12
+ children: Component[] = [];
13
+ #borderFn: (char: string) => string;
14
+ #bgFn?: (text: string) => string;
15
+ #paddingLeft: number;
16
+ #paddingY: number;
17
+
18
+ constructor(paddingLeft = 1, paddingY = 0, borderFn?: (char: string) => string, bgFn?: (text: string) => string) {
19
+ this.#paddingLeft = paddingLeft;
20
+ this.#paddingY = paddingY;
21
+ this.#borderFn = borderFn ?? (s => s);
22
+ this.#bgFn = bgFn;
23
+ }
24
+
25
+ addChild(component: Component): void {
26
+ this.children.push(component);
27
+ }
28
+
29
+ removeChild(component: Component): void {
30
+ const index = this.children.indexOf(component);
31
+ if (index !== -1) {
32
+ this.children.splice(index, 1);
33
+ }
34
+ }
35
+
36
+ clear(): void {
37
+ this.children = [];
38
+ }
39
+
40
+ setBorderFn(borderFn: (char: string) => string): void {
41
+ this.#borderFn = borderFn;
42
+ }
43
+
44
+ setBgFn(bgFn?: (text: string) => string): void {
45
+ this.#bgFn = bgFn;
46
+ }
47
+
48
+ invalidate(): void {
49
+ for (const child of this.children) {
50
+ child.invalidate?.();
51
+ }
52
+ }
53
+
54
+ render(width: number): string[] {
55
+ if (this.children.length === 0) return [];
56
+
57
+ const border = this.#borderFn("┃");
58
+ const leftPad = padding(this.#paddingLeft);
59
+ // Border char takes 1 visible column + paddingLeft
60
+ const contentWidth = Math.max(1, width - 1 - this.#paddingLeft);
61
+
62
+ const childLines: string[] = [];
63
+ for (const child of this.children) {
64
+ childLines.push(...child.render(contentWidth));
65
+ }
66
+
67
+ if (childLines.length === 0) return [];
68
+
69
+ const result: string[] = [];
70
+
71
+ // Top padding
72
+ for (let i = 0; i < this.#paddingY; i++) {
73
+ result.push(this.#applyBg(border, width));
74
+ }
75
+
76
+ // Content
77
+ for (const line of childLines) {
78
+ result.push(this.#applyBg(`${border}${leftPad}${line}`, width));
79
+ }
80
+
81
+ // Bottom padding
82
+ for (let i = 0; i < this.#paddingY; i++) {
83
+ result.push(this.#applyBg(border, width));
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ #applyBg(line: string, width: number): string {
90
+ if (!this.#bgFn) return line;
91
+ return applyBackgroundToLine(line, width, this.#bgFn);
92
+ }
93
+ }
@@ -463,11 +463,15 @@ export class Markdown implements Component {
463
463
  // For mailto: links, strip the prefix before comparing (autolinked emails have
464
464
  // text="foo@bar.com" but href="mailto:foo@bar.com")
465
465
  const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
466
+ const osc8Open = TERMINAL.hyperlinks ? `\x1b]8;;${token.href}\x07` : "";
467
+ const osc8Close = TERMINAL.hyperlinks ? "\x1b]8;;\x07" : "";
466
468
  if (token.text === token.href || token.text === hrefForComparison) {
467
- result += this.#theme.link(this.#theme.underline(linkText)) + stylePrefix;
469
+ result += osc8Open + this.#theme.link(this.#theme.underline(linkText)) + osc8Close + stylePrefix;
468
470
  } else {
469
471
  result +=
472
+ osc8Open +
470
473
  this.#theme.link(this.#theme.underline(linkText)) +
474
+ osc8Close +
471
475
  this.#theme.linkUrl(` (${token.href})`) +
472
476
  stylePrefix;
473
477
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { matchesKey } from "../keys";
12
12
  import type { Component } from "../tui";
13
- import { wrapTextWithAnsi } from "../utils";
13
+ import { truncateToWidth, visibleWidth } from "../utils";
14
14
 
15
15
  /** Tab definition */
16
16
  export interface Tab {
@@ -112,31 +112,64 @@ export class TabBar implements Component {
112
112
 
113
113
  /** Render the tab bar, wrapping to multiple lines if needed */
114
114
  render(width: number): string[] {
115
- const parts: string[] = [];
115
+ const maxWidth = Math.max(1, width);
116
+ const chunks: string[] = [];
116
117
 
117
118
  // Label prefix
118
- parts.push(this.#theme.label(`${this.#label}:`));
119
- parts.push(" ");
119
+ chunks.push(this.#theme.label(`${this.#label}:`));
120
+ chunks.push(" ");
120
121
 
121
122
  // Tab buttons
122
123
  for (let i = 0; i < this.#tabs.length; i++) {
123
124
  const tab = this.#tabs[i];
124
125
  if (i === this.#activeIndex) {
125
- parts.push(this.#theme.activeTab(` ${tab.label} `));
126
+ chunks.push(this.#theme.activeTab(` ${tab.label} `));
126
127
  } else {
127
- parts.push(this.#theme.inactiveTab(` ${tab.label} `));
128
+ chunks.push(this.#theme.inactiveTab(` ${tab.label} `));
128
129
  }
129
130
  if (i < this.#tabs.length - 1) {
130
- parts.push(" ");
131
+ chunks.push(" ");
131
132
  }
132
133
  }
133
134
 
134
135
  // Navigation hint
135
- parts.push(" ");
136
- parts.push(this.#theme.hint("(tab to cycle)"));
136
+ chunks.push(" ");
137
+ chunks.push(this.#theme.hint("(tab to cycle)"));
137
138
 
138
- const line = parts.join("");
139
- const maxWidth = Math.max(1, width);
140
- return wrapTextWithAnsi(line, maxWidth);
139
+ const lines: string[] = [];
140
+ let currentLine = "";
141
+ let currentWidth = 0;
142
+
143
+ for (const chunk of chunks) {
144
+ const chunkWidth = visibleWidth(chunk);
145
+ if (chunkWidth <= 0) {
146
+ continue;
147
+ }
148
+
149
+ if (chunkWidth > maxWidth) {
150
+ if (currentLine) {
151
+ lines.push(currentLine);
152
+ currentLine = "";
153
+ currentWidth = 0;
154
+ }
155
+ lines.push(truncateToWidth(chunk, maxWidth));
156
+ continue;
157
+ }
158
+
159
+ if (currentWidth > 0 && currentWidth + chunkWidth > maxWidth) {
160
+ lines.push(currentLine);
161
+ currentLine = "";
162
+ currentWidth = 0;
163
+ }
164
+
165
+ currentLine += chunk;
166
+ currentWidth += chunkWidth;
167
+ }
168
+
169
+ if (currentLine) {
170
+ lines.push(currentLine);
171
+ }
172
+
173
+ return lines.length > 0 ? lines : [""];
141
174
  }
142
175
  }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export { CancellableLoader } from "./components/cancellable-loader";
15
15
  export { Editor, type EditorTheme, type EditorTopBorder } from "./components/editor";
16
16
  export { Image, type ImageOptions, type ImageTheme } from "./components/image";
17
17
  export { Input } from "./components/input";
18
+ export { LeftBorderBox } from "./components/left-border-box";
18
19
  export { Loader } from "./components/loader";
19
20
  export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown";
20
21
  export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list";
package/src/tui.ts CHANGED
@@ -186,6 +186,7 @@ export class Container implements Component {
186
186
  }
187
187
 
188
188
  render(width: number): string[] {
189
+ width = Math.max(1, width);
189
190
  const lines: string[] = [];
190
191
  for (const child of this.children) {
191
192
  lines.push(...child.render(width));