@trendr/core 0.4.14 → 0.4.16

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/index.js CHANGED
@@ -20,11 +20,12 @@ export { Diff } from './src/diff-view.js'
20
20
  export { computeDiff } from './src/diff-engine.js'
21
21
  export { PickList } from './src/pick-list.js'
22
22
  export { ScrollBox } from './src/scroll-box.js'
23
+ export { HorizontalScrollBox } from './src/horizontal-scroll-box.js'
23
24
  export { SplitPane } from './src/split-pane.js'
24
25
  export { MillerNav } from './src/miller-nav.js'
25
26
  export { MenuBar } from './src/menubar.js'
26
27
  export { Menu } from './src/menu.js'
27
- export { Markdown } from './src/markdown.js'
28
+ export { CodeBlock, Markdown } from './src/markdown.js'
28
29
  export { Task } from './src/task.js'
29
30
  export { Shimmer } from './src/shimmer.js'
30
31
  export { useFocus, useFocusTrap } from './src/focus.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Pyers",
package/src/diff-view.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
3
  import { useInput, useMouse, useLayout, useHitTest, useScrollDrag } from './hooks.js'
4
- import { sliceVisibleRange } from './wrap.js'
4
+ import { measureText, sliceVisibleRange } from './wrap.js'
5
5
  import { computeDiff } from './diff-engine.js'
6
+ import { HorizontalScrollBox } from './horizontal-scroll-box.js'
6
7
 
7
8
  const PALETTE = {
8
9
  addBg: '#10301a', addGutterBg: '#0a2412', addIntraBg: '#1f6e34', addMarker: '#7ee787',
@@ -120,6 +121,8 @@ export function Diff({
120
121
 
121
122
  const oldW = digits(rows, 'oldNo')
122
123
  const newW = digits(rows, 'newNo')
124
+ const gutterWidth = lineNumbers ? oldW + newW + 6 : 2
125
+ const contentWidth = gutterWidth + Math.max(0, ...rows.map((row) => measureText(row.text ?? '')))
123
126
 
124
127
  const headerH = filename ? 1 : 0
125
128
  const h = Math.max(0, layout.height - headerH)
@@ -227,6 +230,11 @@ export function Diff({
227
230
 
228
231
  const visible = h > 0 ? rows.slice(clamped, clamped + h) : []
229
232
  const bodyRows = visible.map((row, i) => renderRow(row, clamped + i))
233
+ const scrollableRows = jsx(HorizontalScrollBox, {
234
+ contentWidth,
235
+ style: { flexDirection: 'column', flexGrow: 1 },
236
+ children: jsx('box', { style: { flexDirection: 'column' }, children: bodyRows }),
237
+ })
230
238
 
231
239
  let body
232
240
  if (rows.length === 0) {
@@ -247,12 +255,12 @@ export function Diff({
247
255
  body = jsxs('box', {
248
256
  style: { flexDirection: 'row', flexGrow: 1 },
249
257
  children: [
250
- jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children: bodyRows }),
258
+ scrollableRows,
251
259
  jsx('box', { style: { flexDirection: 'column', width: barW }, children: bar }),
252
260
  ],
253
261
  })
254
262
  } else {
255
- body = jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children: bodyRows })
263
+ body = scrollableRows
256
264
  }
257
265
 
258
266
  if (!filename) return body
package/src/focus.js CHANGED
@@ -79,13 +79,14 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
79
79
  return state.items.filter(i => i.gen === state.gen).sort((a, b) => a.seq - b.seq)
80
80
  }
81
81
 
82
- function item(name) {
82
+ function item(name, layout) {
83
83
  const existing = state.items.find(i => i.name === name)
84
84
  if (existing) {
85
85
  existing.gen = state.gen
86
86
  existing.seq = state.seq++
87
+ existing.layout = layout
87
88
  } else {
88
- state.items.push({ name, type: 'item', gen: state.gen, seq: state.seq++ })
89
+ state.items.push({ name, type: 'item', gen: state.gen, seq: state.seq++, layout })
89
90
  }
90
91
  if (state.current() == null) state.setCurrent(name)
91
92
  }
@@ -157,6 +158,11 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
157
158
  return state.current()
158
159
  }
159
160
 
161
+ function currentLayout() {
162
+ const name = state.current()
163
+ return state.items.find(i => i.name === name)?.layout ?? null
164
+ }
165
+
160
166
  function findTopLevel(cur, items) {
161
167
  const parent = state.nameToParent.get(cur)
162
168
  if (parent) return items.findIndex(i => i.name === parent)
@@ -220,5 +226,5 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
220
226
  }
221
227
  })
222
228
 
223
- return { item, group, is, focus, push, pop, current }
229
+ return { item, group, is, focus, push, pop, current, currentLayout }
224
230
  }
@@ -0,0 +1,49 @@
1
+ import { jsxs, jsx } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useHitTest, useLayout, useMouse, useTheme } from './hooks.js'
4
+
5
+ export function HorizontalScrollBox({ children, contentWidth, step = 3, indicators = true, style: userStyle }) {
6
+ const { muted = 'gray' } = useTheme()
7
+ const [offset, setOffset] = createSignal(0)
8
+ const layout = useLayout()
9
+ const hitTest = useHitTest()
10
+ const measuredWidth = contentWidth ?? layout.contentWidth ?? 0
11
+ const maxOffset = Math.max(0, measuredWidth - layout.width)
12
+ const clamped = Math.max(0, Math.min(maxOffset, offset()))
13
+
14
+ useMouse((event) => {
15
+ if (event.action !== 'scroll' || !hitTest(event.x, event.y)) return
16
+ if (event.direction !== 'left' && event.direction !== 'right') return
17
+ const next = Math.max(0, Math.min(maxOffset, clamped + (event.direction === 'left' ? -step : step)))
18
+ if (next === clamped) return
19
+ setOffset(next)
20
+ event.stopPropagation()
21
+ })
22
+
23
+ const overlays = []
24
+ if (indicators && clamped > 0) {
25
+ overlays.push(jsx('text', {
26
+ key: 'left',
27
+ style: { position: 'absolute', left: 0, top: 0, color: muted, dim: true, copyIgnore: true },
28
+ children: '‹',
29
+ }))
30
+ }
31
+ if (indicators && clamped < maxOffset) {
32
+ overlays.push(jsx('text', {
33
+ key: 'right',
34
+ style: { position: 'absolute', right: 0, top: 0, color: muted, dim: true, copyIgnore: true },
35
+ children: '›',
36
+ }))
37
+ }
38
+
39
+ return jsxs('box', {
40
+ style: userStyle,
41
+ children: [
42
+ jsx('box', {
43
+ style: { flexDirection: 'row', overflow: 'scroll', scrollOffsetX: clamped },
44
+ children,
45
+ }),
46
+ ...overlays,
47
+ ],
48
+ })
49
+ }
package/src/layout.js CHANGED
@@ -98,17 +98,22 @@ export function computeLayout(node, rect) {
98
98
 
99
99
  if (isScroll && flowChildren.length > 0) {
100
100
  let maxEdge = 0
101
- const heights = []
101
+ const sizes = []
102
102
  for (const child of flowChildren) {
103
103
  const cl = getLeaf(child)?._layout
104
104
  if (cl) {
105
105
  const edge = isRow ? (cl.x + cl.width - innerX) : (cl.y + cl.height - innerY)
106
106
  if (edge > maxEdge) maxEdge = edge
107
- heights.push(isRow ? cl.width : cl.height)
107
+ sizes.push(isRow ? cl.width : cl.height)
108
108
  }
109
109
  }
110
- node._contentHeight = maxEdge
111
- node._childHeights = heights
110
+ if (isRow) {
111
+ node._contentWidth = maxEdge
112
+ node._childWidths = sizes
113
+ } else {
114
+ node._contentHeight = maxEdge
115
+ node._childHeights = sizes
116
+ }
112
117
  }
113
118
 
114
119
  // absolute children anchor to the padding box (inside borders, ignoring
package/src/markdown.js CHANGED
@@ -106,7 +106,21 @@ export function renderInline(s, { accent = 'cyan' } = {}) {
106
106
  return out
107
107
  }
108
108
 
109
- export function Markdown({ text, children, highlight, codeBg = '#1e1e22', style: userStyle }) {
109
+ export function CodeBlock({ value, language, highlight, codeBg = '#1e1e22' }) {
110
+ const shown = highlight ? highlight(value, language) : value
111
+ const rows = shown.split('\n').map((line, key) => jsx('text', {
112
+ key,
113
+ style: { overflow: 'truncate' },
114
+ children: line || ' ',
115
+ }))
116
+
117
+ return jsx('box', {
118
+ style: { flexDirection: 'column', bg: codeBg, paddingX: 1 },
119
+ children: rows,
120
+ })
121
+ }
122
+
123
+ export function Markdown({ text, children, highlight, codeBg = '#1e1e22', codeBlock: CodeBlockComponent = CodeBlock, style: userStyle }) {
110
124
  const { accent = 'cyan', muted = 'gray' } = useTheme()
111
125
  const layout = useLayout()
112
126
  const src = text ?? (Array.isArray(children) ? children.join('') : (children ?? ''))
@@ -126,13 +140,12 @@ export function Markdown({ text, children, highlight, codeBg = '#1e1e22', style:
126
140
  }
127
141
 
128
142
  if (block.type === 'code') {
129
- const raw = block.lines.join('\n')
130
- const shown = highlight ? highlight(raw, block.lang) : raw
131
- const rows = shown.split('\n').map((line, k) => jsx('text', { key: k, style: { overflow: 'truncate' }, children: line || ' ' }))
132
- return jsx('box', {
143
+ return jsx(CodeBlockComponent, {
133
144
  key,
134
- style: { flexDirection: 'column', bg: codeBg, paddingX: 1 },
135
- children: rows,
145
+ value: block.lines.join('\n'),
146
+ language: block.lang,
147
+ highlight,
148
+ codeBg,
136
149
  })
137
150
  }
138
151
 
package/src/renderer.js CHANGED
@@ -220,14 +220,14 @@ function clearOverlayRect(overlayTree, buf) {
220
220
  fillRect(buf, rect.x, rect.y, rect.width, rect.height, ' ', null, null, 0)
221
221
  }
222
222
 
223
- function findScrollContentHeight(node) {
223
+ function findScrollContentSize(node, field) {
224
224
  if (!node) return null
225
- if (node._contentHeight != null) return node._contentHeight
226
- if (node._resolved) return findScrollContentHeight(node._resolved)
225
+ if (node[field] != null) return node[field]
226
+ if (node._resolved) return findScrollContentSize(node._resolved, field)
227
227
  if (node._resolvedChildren) {
228
228
  for (const child of node._resolvedChildren) {
229
- const h = findScrollContentHeight(child)
230
- if (h != null) return h
229
+ const size = findScrollContentSize(child, field)
230
+ if (size != null) return size
231
231
  }
232
232
  }
233
233
  return null
@@ -251,7 +251,8 @@ function updateOverlayLayouts(node) {
251
251
  if (node._instance) {
252
252
  const rect = node._availableRect ?? node._layout
253
253
  if (rect) {
254
- const ch = findScrollContentHeight(node)
254
+ const ch = findScrollContentSize(node, '_contentHeight')
255
+ const cw = findScrollContentSize(node, '_contentWidth')
255
256
  if (!node._instance.layout) node._instance.layout = { x: 0, y: 0, width: 0, height: 0 }
256
257
  const target = node._instance.layout
257
258
  target.x = rect.x
@@ -259,6 +260,7 @@ function updateOverlayLayouts(node) {
259
260
  target.width = rect.width
260
261
  target.height = rect.height
261
262
  target.contentHeight = ch
263
+ target.contentWidth = cw
262
264
  target.childHeights = findScrollChildHeights(node)
263
265
  }
264
266
  }
@@ -417,8 +419,9 @@ function paintTree(node, buf, clip, offset, prevBuf) {
417
419
  let childOffset = offset
418
420
  let childPrevBuf = prevBuf
419
421
  if (style.overflow === 'scroll') {
422
+ const scrollX = style.scrollOffsetX ?? 0
420
423
  const scrollY = style.scrollOffset ?? 0
421
- childOffset = { x: offset?.x ?? 0, y: (offset?.y ?? 0) - scrollY }
424
+ childOffset = { x: (offset?.x ?? 0) - scrollX, y: (offset?.y ?? 0) - scrollY }
422
425
  childPrevBuf = null
423
426
  }
424
427
 
@@ -807,15 +810,17 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
807
810
  for (const inst of instances.values()) {
808
811
  const rect = inst.node?._availableRect ?? inst.node?._layout
809
812
  if (!rect) continue
810
- const ch = findScrollContentHeight(inst.node)
813
+ const ch = findScrollContentSize(inst.node, '_contentHeight')
814
+ const cw = findScrollContentSize(inst.node, '_contentWidth')
811
815
  if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
812
816
  const p = inst.layout
813
- if (p.width !== rect.width || p.height !== rect.height || p.contentHeight !== ch) changed = true
817
+ if (p.width !== rect.width || p.height !== rect.height || p.contentHeight !== ch || p.contentWidth !== cw) changed = true
814
818
  p.x = rect.x
815
819
  p.y = rect.y
816
820
  p.width = rect.width
817
821
  p.height = rect.height
818
822
  p.contentHeight = ch
823
+ p.contentWidth = cw
819
824
  p.childHeights = findScrollChildHeights(inst.node)
820
825
  }
821
826
  return changed
@@ -990,10 +995,11 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
990
995
  for (const inst of instances.values()) {
991
996
  const rect = inst.node?._availableRect ?? inst.node?._layout
992
997
  if (!rect) continue
993
- const ch = findScrollContentHeight(inst.node)
998
+ const ch = findScrollContentSize(inst.node, '_contentHeight')
999
+ const cw = findScrollContentSize(inst.node, '_contentWidth')
994
1000
  if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
995
1001
  const prev = inst.layout
996
- if (prev.width !== rect.width || prev.height !== rect.height || prev.contentHeight !== ch) {
1002
+ if (prev.width !== rect.width || prev.height !== rect.height || prev.contentHeight !== ch || prev.contentWidth !== cw) {
997
1003
  layoutChanged = true
998
1004
  }
999
1005
  prev.x = rect.x
@@ -1001,6 +1007,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
1001
1007
  prev.width = rect.width
1002
1008
  prev.height = rect.height
1003
1009
  prev.contentHeight = ch
1010
+ prev.contentWidth = cw
1004
1011
  prev.childHeights = findScrollChildHeights(inst.node)
1005
1012
  }
1006
1013
 
@@ -1014,13 +1021,15 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
1014
1021
  for (const inst of instances.values()) {
1015
1022
  const rect = inst.node?._availableRect ?? inst.node?._layout
1016
1023
  if (!rect) continue
1017
- const ch = findScrollContentHeight(inst.node)
1024
+ const ch = findScrollContentSize(inst.node, '_contentHeight')
1025
+ const cw = findScrollContentSize(inst.node, '_contentWidth')
1018
1026
  if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
1019
1027
  inst.layout.x = rect.x
1020
1028
  inst.layout.y = rect.y
1021
1029
  inst.layout.width = rect.width
1022
1030
  inst.layout.height = rect.height
1023
1031
  inst.layout.contentHeight = ch
1032
+ inst.layout.contentWidth = cw
1024
1033
  inst.layout.childHeights = findScrollChildHeights(inst.node)
1025
1034
  inst._dirty = true
1026
1035
  }
package/src/scroll-box.js CHANGED
@@ -2,7 +2,7 @@ import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
3
  import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
4
4
 
5
- export function ScrollBox({ children, focused = true, scrollOffset: offsetProp, onScroll, scrollbar = false, gap = 0, thumbChar = '\u2588', trackChar = '\u2502', style: userStyle }) {
5
+ export function ScrollBox({ children, focused = true, followFocus, focusPadding = 0, scrollOffset: offsetProp, onScroll, scrollbar = false, gap = 0, thumbChar = '\u2588', trackChar = '\u2502', style: userStyle }) {
6
6
  const { accent = 'cyan', muted = 'gray' } = useTheme()
7
7
  const [offsetInternal, setOffsetInternal] = createSignal(0)
8
8
  const layout = useLayout()
@@ -22,6 +22,21 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
22
22
  const clamp = (v) => Math.max(0, Math.min(maxOffset, v))
23
23
  const clamped = clamp(offset)
24
24
 
25
+ if (followFocus) {
26
+ const target = typeof followFocus === 'function' ? followFocus() : followFocus.currentLayout?.()
27
+ if (target && visibleH > 0) {
28
+ const top = target.y - layout.y + clamped
29
+ const bottom = top + Math.max(1, target.height ?? 1)
30
+ const padding = Math.max(0, focusPadding)
31
+ const next = top < clamped + padding
32
+ ? top - padding
33
+ : bottom > clamped + visibleH - padding
34
+ ? bottom - visibleH + padding
35
+ : clamped
36
+ if (clamp(next) !== clamped) setOffset(next)
37
+ }
38
+ }
39
+
25
40
  useInput(({ key, ctrl }) => {
26
41
  if (!focused) return
27
42
  if (contentH <= visibleH) return