Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/tmux-keybindings/herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,5 @@ id = "bindings"
title = "Tmux keybindings"
placement = "popup"
width = 84
height = 24
height = 30
command = ["node", "index.ts", "bindings"]
126 changes: 60 additions & 66 deletions plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,81 @@
import { PANEL_GROUPS, type BindingDescription, type BindingGroup } from '#tmux-keybindings/domain/keybinding-profile';
import { PANEL_GROUPS } from '#tmux-keybindings/domain/keybinding-profile';
import { DialogPalette } from '#tmux-keybindings/presentation/terminal/dialog-palette';
import { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment';
import { TerminalLine } from '#tmux-keybindings/presentation/terminal/terminal-line';

const maximumChordWidth = 12;
const minimumChordWidth = 7;
const chordWidthRatio = 0.28;
const minimumWidth = 20;
const minimumHeight = 4;
const shortcutWidth = 20;

export class BindingsPanelRenderer {
render(width: number, height: number): string {
const safeWidth = Math.max(20, width);
const safeHeight = Math.max(4, height);
const contentWidth = safeWidth - 4;
const lines = safeWidth >= 72 ? this.wideLines(contentWidth) : this.narrowLines(contentWidth);
const header = this.centre('TMUX KEYBINDINGS', contentWidth);
const shortcuts = this.shortcutLines(contentWidth);
const divider = this.centre('━'.repeat(Math.min(contentWidth, 42)), contentWidth);
const body = [header, ...shortcuts, divider, '', ...lines];
const visible = body.slice(0, safeHeight - 1);

if (body.length > visible.length) {
visible[visible.length - 1] = this.truncate('… resize dialog to see all bindings', contentWidth);
}

return visible.map((line) => ` ${this.truncate(line, contentWidth)}`).join('\n');
const safeWidth = Math.max(minimumWidth, width);
const safeHeight = Math.max(minimumHeight, height);
const palette = DialogPalette.tmux();
const contentRows = this.contentRows(safeWidth, palette);
const footer = this.footer(palette);
const visibleRows = this.visibleRows(contentRows, footer, safeHeight, palette);

return visibleRows.map((segments) => TerminalLine.from(segments, safeWidth, palette.background).render()).join('\n');
}

private narrowLines(width: number): readonly string[] {
return PANEL_GROUPS.flatMap((group, index) => [...(index === 0 ? [] : ['']), this.sectionHeading(group.title, width), ...group.bindings.map((binding) => this.bindingLine(binding, width))]);
}

private wideLines(width: number): readonly string[] {
const columnWidth = Math.floor((width - 3) / 2);
const columns: readonly BindingGroup[][] = [[PANEL_GROUPS[0] as BindingGroup, PANEL_GROUPS[1] as BindingGroup], [PANEL_GROUPS[2] as BindingGroup]];

const rendered = columns.map((groups) =>
groups.flatMap((group, index) => [...(index === 0 ? [] : ['']), this.sectionHeading(group.title, columnWidth), ...group.bindings.map((binding) => this.bindingLine(binding, columnWidth))]),
);

const rowCount = Math.max(...rendered.map((column) => column.length));
const lines: string[] = [];

for (let row = 0; row < rowCount; row += 1) {
const left = (rendered[0]?.[row] ?? '').padEnd(columnWidth);
const right = rendered[1]?.[row] ?? '';

lines.push(`${left} │ ${right}`.trimEnd());
private contentRows(width: number, palette: DialogPalette): readonly (readonly StyledSegment[])[] {
const rows: (readonly StyledSegment[])[] = [
this.title(width, palette),
this.text('available commands and configured shortcuts', palette.mutedText, palette),
this.text('', palette.primaryText, palette),
];

for (const [groupIndex, group] of PANEL_GROUPS.entries()) {
if (groupIndex > 0) {
rows.push(this.text('', palette.primaryText, palette));
}

rows.push(this.text(group.title.toLowerCase(), palette.sectionHeading, palette, true));

for (const binding of group.bindings) {
rows.push([
StyledSegment.from(binding.chord.padEnd(shortcutWidth), palette.shortcut, palette.background, false),
StyledSegment.from(binding.description, palette.primaryText, palette.background, false),
]);
}
}

return lines;
return rows;
}

private bindingLine(binding: BindingDescription, width: number): string {
const chordWidth = Math.min(maximumChordWidth, Math.max(minimumChordWidth, Math.floor(width * chordWidthRatio)));
private title(width: number, palette: DialogPalette): readonly StyledSegment[] {
const title = 'tmux keybindings';
const badge = 'esc close';
const spacing = ' '.repeat(Math.max(1, width - Array.from(title).length - Array.from(badge).length));

return `${binding.chord.padEnd(chordWidth)} ${binding.description}`;
return [
StyledSegment.from(title, palette.primaryText, palette.background, true),
StyledSegment.from(spacing, palette.primaryText, palette.background, false),
StyledSegment.from(badge, palette.closeBadgeText, palette.closeBadgeBackground, true),
];
}

private shortcutLines(width: number): readonly string[] {
if (width >= 48) {
return [this.centre('Open/close Option+Command+T • Esc closes', width)];
}

if (width >= 25) {
return [this.centre('Option+Command+T', width), this.centre('Esc closes', width)];
}

return [this.centre('Esc closes', width)];
private footer(palette: DialogPalette): readonly StyledSegment[] {
return this.text('toggle Option+Command+T close Esc', palette.mutedText, palette);
}

private sectionHeading(title: string, width: number): string {
const heading = title.toUpperCase();
const rule = '─'.repeat(Math.max(0, width - heading.length - 1));

return `${heading} ${rule}`;
}
private visibleRows(contentRows: readonly (readonly StyledSegment[])[], footer: readonly StyledSegment[], height: number, palette: DialogPalette): readonly (readonly StyledSegment[])[] {
const contentHeight = height - 1;
const visible = contentRows.slice(0, contentHeight);

private centre(value: string, width: number): string {
return value.padStart(Math.max(value.length, Math.floor((width + value.length) / 2)));
}
if (contentRows.length > contentHeight) {
visible[visible.length - 1] = this.text('... resize dialog to see all bindings', palette.mutedText, palette);
}

private truncate(value: string, width: number): string {
if (value.length <= width) {
return value;
while (visible.length < contentHeight) {
visible.push(this.text('', palette.primaryText, palette));
}

return `${value.slice(0, Math.max(0, width - 1))}…`;
return [...visible, footer];
}

private text(value: string, foreground: string, palette: DialogPalette, emphasized = false): readonly StyledSegment[] {
return [StyledSegment.from(value, foreground, palette.background, emphasized)];
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
const controlC = 0x03;
const escape = 0x1b;
const legacyAltT = 0x74;

export class PanelCloseShortcut {
accept(input: Uint8Array): boolean {
for (const byte of input) {
if (byte === escape || byte === controlC) {
return true;
}
if (input.length === 1) {
return input[0] === escape || input[0] === controlC;
}

return false;
return input.length === 2 && input[0] === escape && input[1] === legacyAltT;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/** Immutable colours used to render the tmux keybindings dialog. */
export class DialogPalette {
private readonly backgroundValue: string;
private readonly primaryTextValue: string;
private readonly mutedTextValue: string;
private readonly sectionHeadingValue: string;
private readonly shortcutValue: string;
private readonly closeBadgeBackgroundValue: string;
private readonly closeBadgeTextValue: string;

private constructor(
backgroundValue: string,
primaryTextValue: string,
mutedTextValue: string,
sectionHeadingValue: string,
shortcutValue: string,
closeBadgeBackgroundValue: string,
closeBadgeTextValue: string,
) {
this.backgroundValue = backgroundValue;
this.primaryTextValue = primaryTextValue;
this.mutedTextValue = mutedTextValue;
this.sectionHeadingValue = sectionHeadingValue;
this.shortcutValue = shortcutValue;
this.closeBadgeBackgroundValue = closeBadgeBackgroundValue;
this.closeBadgeTextValue = closeBadgeTextValue;
}

/** Creates the tmux help-dialog palette. */
static tmux(): DialogPalette {
return new DialogPalette('#1a1b26', '#c0caf5', '#737aa2', '#7dcfff', '#bb9af7', '#7aa2f7', '#1a1b26');
}

/** Returns the dialog background colour. */
get background(): string {
return this.backgroundValue;
}

/** Returns the primary text colour. */
get primaryText(): string {
return this.primaryTextValue;
}

/** Returns the muted text colour. */
get mutedText(): string {
return this.mutedTextValue;
}

/** Returns the section-heading colour. */
get sectionHeading(): string {
return this.sectionHeadingValue;
}

/** Returns the shortcut colour. */
get shortcut(): string {
return this.shortcutValue;
}

/** Returns the close-badge background colour. */
get closeBadgeBackground(): string {
return this.closeBadgeBackgroundValue;
}

/** Returns the close-badge text colour. */
get closeBadgeText(): string {
return this.closeBadgeTextValue;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/** Immutable terminal text with explicit foreground, background, and emphasis. */
export class StyledSegment {
private readonly textValue: string;
private readonly foregroundValue: string;
private readonly backgroundValue: string;
private readonly emphasizedValue: boolean;

private constructor(textValue: string, foregroundValue: string, backgroundValue: string, emphasizedValue: boolean) {
this.textValue = textValue;
this.foregroundValue = foregroundValue;
this.backgroundValue = backgroundValue;
this.emphasizedValue = emphasizedValue;
}

/** Creates a styled terminal-text segment. */
static from(text: string, foreground: string, background: string, emphasized: boolean): StyledSegment {
return new StyledSegment(text, foreground, background, emphasized);
}

/** Returns the unstyled text. */
get text(): string {
return this.textValue;
}

/** Returns the foreground colour. */
get foreground(): string {
return this.foregroundValue;
}

/** Returns the background colour. */
get background(): string {
return this.backgroundValue;
}

/** Reports whether the segment is emphasized. */
get emphasized(): boolean {
return this.emphasizedValue;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment';

const escape = String.fromCodePoint(0x1b);

/** Renders immutable styled segments to one fixed-width terminal row. */
export class TerminalLine {
private readonly segments: readonly StyledSegment[];
private readonly width: number;
private readonly background: string;

private constructor(segments: readonly StyledSegment[], width: number, background: string) {
this.segments = segments;
this.width = width;
this.background = background;
}

/** Creates a fixed-width terminal row from concrete styled segments. */
static from(segments: readonly StyledSegment[], width: number, background: string): TerminalLine {
return new TerminalLine(Object.freeze([...segments]), width, background);
}

/** Renders ANSI styling after measuring, truncating, and padding unstyled code points. */
render(): string {
const renderedSegments: string[] = [];

let visibleWidth = 0;

for (const segment of this.segments) {
const remainingWidth = this.width - visibleWidth;

if (remainingWidth <= 0) {
break;
}

const text = this.truncate(segment.text, remainingWidth);

if (text.length === 0) {
continue;
}

renderedSegments.push(this.renderSegment(segment, text));
visibleWidth += this.visibleWidth(text);
}

const padding = ' '.repeat(Math.max(0, this.width - visibleWidth));

return `${renderedSegments.join('')}${this.backgroundSequence(this.background)}${padding}${escape}[0m`;
}

private renderSegment(segment: StyledSegment, text: string): string {
const emphasis = segment.emphasized ? `${escape}[1m` : `${escape}[22m`;

return `${this.backgroundSequence(segment.background)}${emphasis}${this.foregroundSequence(segment.foreground)}${text}`;
}

private visibleWidth(value: string): number {
return Array.from(value).length;
}

private truncate(value: string, width: number): string {
const codePoints = Array.from(value);

if (codePoints.length <= width) {
return value;
}

if (width === 1) {
return '…';
}

return `${codePoints.slice(0, Math.max(0, width - 1)).join('')}…`;
}

private foregroundSequence(colour: string): string {
const [red, green, blue] = this.parseColour(colour);

return `${escape}[38;2;${red};${green};${blue}m`;
}

private backgroundSequence(colour: string): string {
const [red, green, blue] = this.parseColour(colour);

return `${escape}[48;2;${red};${green};${blue}m`;
}

private parseColour(colour: string): readonly [number, number, number] {
const hexadecimal = colour.startsWith('#') ? colour.slice(1) : colour;

if (!/^[0-9a-f]{6}$/iu.test(hexadecimal)) {
return [0, 0, 0];
}

return [Number.parseInt(hexadecimal.slice(0, 2), 16), Number.parseInt(hexadecimal.slice(2, 4), 16), Number.parseInt(hexadecimal.slice(4, 6), 16)];
}
Comment thread
gocanto marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('tmux-keybindings plugin contract', () => {
expect(manifest).toContain('on = "workspace.created"');
expect(manifest).toContain('placement = "popup"');
expect(manifest).toContain('width = 84');
expect(manifest).toContain('height = 24');
expect(manifest).toContain('height = 30');
});

it('uses concern aliases and is listed in the root catalogue', () => {
Expand Down
Loading
Loading