mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
510 lines
15 KiB
TypeScript
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
|
|
}
|
|
}
|