hermes-agent/ui-tui/src/lib/widgetGrid.ts
hermes-seaeye[bot] 8208fc5270
fmt(js): npm run fix on merge (#68977)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-21 23:05:16 +00:00

510 lines
15 KiB
TypeScript

export interface WidgetGridItem {
colSpan?: number
colStart?: number
id: string
span?: number
}
export interface WidgetGridCell {
col: number
id: string
span: number
width: number
}
export interface WidgetGridLayout {
columnCount: number
columns: number[]
rows: WidgetGridCell[][]
}
export interface WidgetGridLayoutOptions {
/**
* Explicit column count (equal shares) or a grid-template-style track list
* (fixed cell counts / weighted `fr` shares). Omitted: auto from width.
*/
columns?: GridTrackSize[] | number
gap?: number
items: WidgetGridItem[]
maxColumns?: number
minColumnWidth?: number
width: number
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const toInt = (value: number, fallback: number) => {
if (!Number.isFinite(value)) {
return fallback
}
return Math.trunc(value)
}
const columnCountForWidth = (width: number, minColumnWidth: number, gap: number, maxColumns: number) => {
const safeWidth = Math.max(1, toInt(width, 1))
const safeMinWidth = Math.max(1, toInt(minColumnWidth, 1))
const safeGap = Math.max(0, toInt(gap, 0))
const safeMaxColumns = Math.max(1, toInt(maxColumns, 1))
const count = Math.floor((safeWidth + safeGap) / (safeMinWidth + safeGap))
return clamp(count || 1, 1, safeMaxColumns)
}
const buildColumnWidths = (width: number, columnCount: number, gap: number) =>
resolveGridTracks(
width,
gap,
Array.from({ length: Math.max(1, toInt(columnCount, 1)) }, () => ({ fr: 1 }))
)
const spanWidth = (columns: number[], colStart: number, span: number, gap: number) => {
const end = Math.min(columns.length, colStart + span)
const width = columns.slice(colStart, end).reduce((acc, value) => acc + value, 0)
const safeGap = Math.max(0, toInt(gap, 0))
return width + safeGap * Math.max(0, end - colStart - 1)
}
export const widgetGridSpanWidth = spanWidth
const itemSpan = (item: WidgetGridItem, columnCount: number) =>
clamp(toInt(item.colSpan ?? item.span ?? 1, 1), 1, columnCount)
const itemColStart = (item: WidgetGridItem, columnCount: number, span: number) => {
if (item.colStart === undefined) {
return null
}
return clamp(toInt(item.colStart, 0), 0, Math.max(0, columnCount - span))
}
const rangeIsFree = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
if (occupied[col]) {
return false
}
}
return true
}
const occupyRange = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
occupied[col] = true
}
}
const firstFreeCol = (occupied: boolean[], span: number) => {
for (let col = 0; col <= occupied.length - span; col++) {
if (rangeIsFree(occupied, col, span)) {
return col
}
}
return null
}
const sortRow = (row: WidgetGridCell[]) => row.sort((a, b) => a.col - b.col)
export function layoutWidgetGrid({
columns: requestedColumns,
gap = 1,
items,
maxColumns = 3,
minColumnWidth = 28,
width
}: WidgetGridLayoutOptions): WidgetGridLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeWidth = Math.max(1, toInt(width, 1))
const maxDrawableColumns = safeGap > 0 ? Math.max(1, Math.floor((safeWidth + safeGap) / (safeGap + 1))) : safeWidth
const trackList = Array.isArray(requestedColumns) && requestedColumns.length ? requestedColumns : null
const columnCount = trackList
? trackList.length
: requestedColumns === undefined || Array.isArray(requestedColumns)
? columnCountForWidth(safeWidth, minColumnWidth, safeGap, maxColumns)
: clamp(toInt(requestedColumns, 1), 1, maxDrawableColumns)
const columns = trackList
? resolveGridTracks(safeWidth, safeGap, trackList)
: buildColumnWidths(width, columnCount, safeGap)
const rows: WidgetGridCell[][] = []
let row: WidgetGridCell[] = []
let occupied = Array.from({ length: columnCount }, () => false)
const pushRow = () => {
rows.push(sortRow(row))
row = []
occupied = Array.from({ length: columnCount }, () => false)
}
for (const item of items) {
const wantedSpan = itemSpan(item, columnCount)
const explicitCol = itemColStart(item, columnCount, wantedSpan)
let col = explicitCol ?? firstFreeCol(occupied, wantedSpan)
if (col === null || (explicitCol !== null && !rangeIsFree(occupied, explicitCol, wantedSpan))) {
if (row.length > 0) {
pushRow()
}
col = explicitCol ?? 0
}
row.push({
col,
id: item.id,
span: wantedSpan,
width: spanWidth(columns, col, wantedSpan, safeGap)
})
occupyRange(occupied, col, wantedSpan)
}
if (row.length > 0) {
rows.push(sortRow(row))
}
return { columnCount, columns, rows }
}
// ── Track solver (grid-template-columns/rows for character cells) ──────────
//
// A track is either a fixed cell count (`number`) or a fractional share
// (`{ fr, min? }`) of whatever space the fixed tracks leave over — the same
// fixed-vs-flex split the desktop pane-shell's track model uses, solved in
// integer terminal cells. `min` defaults to 1 so a track never disappears.
export type GridTrackSize = number | { fr: number; min?: number }
const trackFr = (track: GridTrackSize) => (typeof track === 'number' ? 0 : Math.max(0.0001, track.fr))
const trackMin = (track: GridTrackSize) => (typeof track === 'number' ? 1 : Math.max(1, toInt(track.min ?? 1, 1)))
/** Floor-divide `budget` across `weights`, spreading the remainder left-to-right. */
const distributeByWeight = (budget: number, weights: number[]) => {
const total = weights.reduce((acc, w) => acc + w, 0)
if (total <= 0 || budget <= 0) {
return weights.map(() => 0)
}
const shares = weights.map(w => Math.floor((budget * w) / total))
let remainder = budget - shares.reduce((acc, s) => acc + s, 0)
for (let i = 0; remainder > 0 && i < shares.length; i++, remainder--) {
shares[i]! += 1
}
return shares
}
/**
* Solve track sizes for a `total`-cell axis with `gap` cells between tracks.
* Fixed tracks take their size; `fr` tracks share the leftover by weight,
* re-pinning any track that falls below its `min` and re-solving the rest.
* Every track ends up ≥ 1 cell; when the axis genuinely can't fit, the
* overflow is shaved off the trailing tracks so the sum never exceeds the
* drawable width unless every track is already at 1.
*/
export function resolveGridTracks(total: number, gap: number, tracks: GridTrackSize[]): number[] {
const count = tracks.length
if (!count) {
return []
}
const safeGap = Math.max(0, toInt(gap, 0))
const usable = Math.max(count, toInt(total, count) - safeGap * (count - 1))
const sizes = Array.from({ length: count }, () => 0)
let remaining = usable
let unpinned: number[] = []
tracks.forEach((track, idx) => {
if (typeof track === 'number') {
sizes[idx] = Math.max(1, toInt(track, 1))
remaining -= sizes[idx]!
} else {
unpinned.push(idx)
}
})
while (unpinned.length) {
const shares = distributeByWeight(
Math.max(0, remaining),
unpinned.map(idx => trackFr(tracks[idx]!))
)
const violating = unpinned.filter((idx, i) => shares[i]! < trackMin(tracks[idx]!))
if (!violating.length) {
unpinned.forEach((idx, i) => {
sizes[idx] = shares[i]!
})
break
}
for (const idx of violating) {
sizes[idx] = trackMin(tracks[idx]!)
remaining -= sizes[idx]!
}
unpinned = unpinned.filter(idx => !violating.includes(idx))
}
let overflow = sizes.reduce((acc, s) => acc + s, 0) - usable
for (let idx = count - 1; idx >= 0 && overflow > 0; idx--) {
const give = Math.min(overflow, sizes[idx]! - 1)
sizes[idx] -= give
overflow -= give
}
return sizes
}
// ── 2D area layout (the workspace mode) ────────────────────────────────────
//
// `layoutGridAreas` is the full two-axis grid: explicit column AND row tracks,
// items with `col`/`row` pins and `colSpan`/`rowSpan`, dense first-fit
// auto-placement, and each cell solved to an absolute `{ x, y, width, height }`
// rect. It is the terminal-cell equivalent of the desktop zone editor's
// `GridLayout` (rowPercents / columnPercents / cellChildMap with merged
// spans) — the renderer absolutely positions each rect, which is what makes
// `rowSpan` representable at all under Yoga flexbox.
export interface GridAreaItem {
id: string
/** Explicit column start (0-based). Explicitly pinned items may overlap. */
col?: number
colSpan?: number
/** Explicit row start (0-based). Rows grow implicitly as needed. */
row?: number
rowSpan?: number
}
export interface GridAreaCell {
col: number
colSpan: number
height: number
id: string
row: number
rowSpan: number
width: number
x: number
y: number
}
export interface GridAreasLayout {
cells: GridAreaCell[]
columnCount: number
columnSizes: number[]
height: number
rowCount: number
rowSizes: number[]
width: number
}
export interface GridAreasOptions {
/** Column tracks — a count (equal `fr` shares) or explicit track list. */
columns: GridTrackSize[] | number
gap?: number
height: number
items: GridAreaItem[]
rowGap?: number
/** Row tracks. Omitted or short: implicit rows are `{ fr: 1 }`. */
rows?: GridTrackSize[] | number
width: number
}
const normalizeTracks = (tracks: GridTrackSize[] | number | undefined, fallbackCount: number): GridTrackSize[] => {
if (Array.isArray(tracks) && tracks.length) {
return tracks
}
const count = Math.max(1, toInt(typeof tracks === 'number' ? tracks : fallbackCount, 1))
return Array.from({ length: count }, () => ({ fr: 1 }))
}
interface PlacedArea {
col: number
colSpan: number
id: string
row: number
rowSpan: number
}
const ensureOccupancyRows = (occupied: boolean[][], rowCount: number, columnCount: number) => {
while (occupied.length < rowCount) {
occupied.push(Array.from({ length: columnCount }, () => false))
}
}
const areaIsFree = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
if (occupied[r]![c]) {
return false
}
}
}
return true
}
const occupyArea = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
occupied[r]![c] = true
}
}
}
/** Dense first-fit auto-placement (CSS `grid-auto-flow: row dense`). */
const placeGridItems = (items: GridAreaItem[], columnCount: number): PlacedArea[] => {
const occupied: boolean[][] = [Array.from({ length: columnCount }, () => false)]
return items.map(item => {
const colSpan = clamp(toInt(item.colSpan ?? 1, 1), 1, columnCount)
const rowSpan = Math.max(1, toInt(item.rowSpan ?? 1, 1))
const pinnedCol = item.col === undefined ? null : clamp(toInt(item.col, 0), 0, columnCount - colSpan)
const pinnedRow = item.row === undefined ? null : Math.max(0, toInt(item.row, 0))
let row: number
let col: number
if (pinnedRow !== null && pinnedCol !== null) {
row = pinnedRow
col = pinnedCol
} else if (pinnedRow !== null) {
// Pinned row: first free column run, overlapping at col 0 when full.
col = 0
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, pinnedRow, c, rowSpan, colSpan)) {
col = c
break
}
}
row = pinnedRow
} else {
// Auto (or pinned col): scan row-major for the first fitting rect. New
// rows are always empty, so the scan terminates.
row = 0
col = pinnedCol ?? 0
for (let r = 0; ; r++) {
const found =
pinnedCol !== null
? areaIsFree(occupied, r, pinnedCol, rowSpan, colSpan)
? pinnedCol
: null
: (() => {
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, r, c, rowSpan, colSpan)) {
return c
}
}
return null
})()
if (found !== null) {
row = r
col = found
break
}
}
}
occupyArea(occupied, row, col, rowSpan, colSpan)
return { col, colSpan, id: item.id, row, rowSpan }
})
}
const trackOffsets = (sizes: number[], gap: number) => {
const offsets: number[] = []
let cursor = 0
for (const size of sizes) {
offsets.push(cursor)
cursor += size + gap
}
return offsets
}
const spanSize = (sizes: number[], start: number, span: number, gap: number) => {
const end = Math.min(sizes.length, start + span)
return sizes.slice(start, end).reduce((acc, s) => acc + s, 0) + gap * Math.max(0, end - start - 1)
}
export function layoutGridAreas({
columns,
gap = 1,
height,
items,
rowGap = 0,
rows,
width
}: GridAreasOptions): GridAreasLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeRowGap = Math.max(0, toInt(rowGap, 0))
const safeWidth = Math.max(1, toInt(width, 1))
const safeHeight = Math.max(1, toInt(height, 1))
const columnTracks = normalizeTracks(columns, 1)
const columnCount = columnTracks.length
const placed = placeGridItems(items, columnCount)
const placedRowCount = placed.reduce((acc, area) => Math.max(acc, area.row + area.rowSpan), 0)
const explicitRowTracks = rows === undefined ? [] : normalizeTracks(rows, 1)
const rowCount = Math.max(1, explicitRowTracks.length, placedRowCount)
const rowTracks: GridTrackSize[] = Array.from({ length: rowCount }, (_, idx) => explicitRowTracks[idx] ?? { fr: 1 })
const columnSizes = resolveGridTracks(safeWidth, safeGap, columnTracks)
const rowSizes = resolveGridTracks(safeHeight, safeRowGap, rowTracks)
const columnStarts = trackOffsets(columnSizes, safeGap)
const rowStarts = trackOffsets(rowSizes, safeRowGap)
const cells = placed.map(area => {
const row = Math.min(area.row, rowCount - 1)
return {
col: area.col,
colSpan: area.colSpan,
height: spanSize(rowSizes, row, area.rowSpan, safeRowGap),
id: area.id,
row,
rowSpan: area.rowSpan,
width: spanSize(columnSizes, area.col, area.colSpan, safeGap),
x: columnStarts[area.col] ?? 0,
y: rowStarts[row] ?? 0
}
})
return {
cells,
columnCount,
columnSizes,
height: safeHeight,
rowCount,
rowSizes,
width: safeWidth
}
}