mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
fix(beehive): separate provider input and action focus surfaces
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -10,6 +10,10 @@ export class ProviderDialog {
|
||||
private surface: BoxRenderable;
|
||||
private title: TextRenderable;
|
||||
private rows: TextRenderable[] = [];
|
||||
private labels: TextRenderable[] = [];
|
||||
private borders: TextRenderable[] = [];
|
||||
private actionBorder: TextRenderable;
|
||||
private help: TextRenderable;
|
||||
private actions: TextRenderable;
|
||||
private notice?: TextRenderable;
|
||||
private index = 0;
|
||||
@@ -30,11 +34,20 @@ export class ProviderDialog {
|
||||
this.surface.add(this.notice);
|
||||
}
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const row = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 2, fg: palette.text, onMouseDown: () => { this.index = i; this.paint(); } });
|
||||
const focus = () => { this.index = i; this.paint(); };
|
||||
const label = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, fg: palette.muted, onMouseDown: focus });
|
||||
this.surface.add(label); this.labels.push(label);
|
||||
const border = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, onMouseDown: focus });
|
||||
this.surface.add(border); this.borders.push(border);
|
||||
const row = new TextRenderable(renderer, { position: 'absolute', left: 4, height: 1, fg: palette.text, onMouseDown: focus });
|
||||
this.surface.add(row); this.rows.push(row);
|
||||
}
|
||||
this.actions = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 2, fg: palette.text, onMouseDown: () => this.complete() });
|
||||
this.surface.add(this.actions); this.paint();
|
||||
this.actions = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, fg: palette.text, onMouseDown: () => { this.index = this.fields.length; this.paint(); this.complete(); } });
|
||||
this.actionBorder = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, onMouseDown: () => this.complete() });
|
||||
this.surface.add(this.actionBorder);
|
||||
this.actions.left = 4;
|
||||
this.help = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, fg: palette.muted });
|
||||
this.surface.add(this.help); this.surface.add(this.actions); this.paint();
|
||||
}
|
||||
updateSecretLength(length: number) { this.secretLength = length; this.paint(); }
|
||||
paste(value: string) {
|
||||
@@ -83,16 +96,25 @@ export class ProviderDialog {
|
||||
if (selected >= this.offset + capacity) this.offset = selected - capacity + 1;
|
||||
this.rows.forEach((row, i) => {
|
||||
const field = this.fields[i]!; row.visible = i >= this.offset && i < this.offset + capacity;
|
||||
row.top = 3 + (i - this.offset) * 3; row.width = width - 6;
|
||||
const label = this.labels[i]!; label.visible = row.visible;
|
||||
label.top = 3 + (i - this.offset) * 3; label.width = width - 6; label.content = field.label;
|
||||
row.top = label.top + 1; row.width = width - 10;
|
||||
const value = field.secret ? '•'.repeat(Math.min(this.secretLength, width - 10)) : field.value ?? '';
|
||||
row.content = `${field.label}${field.choices ? ' ← →' : ''}\n${value.slice(-(width - 6)) || ' '}`;
|
||||
row.bg = this.index === i ? palette.selected : palette.surface;
|
||||
row.fg = this.index === i ? palette.selectedText : palette.text;
|
||||
const focused = this.index === i;
|
||||
const prompt = field.secret ? (field.label.includes('blank keeps saved') ? 'Leave blank to keep saved' : 'Enter API key') : `Enter ${field.label.toLowerCase()}`;
|
||||
const suffix = field.choices ? ' ← →' : '';
|
||||
row.content = (value || prompt).slice(-(width - 10 - suffix.length)).padEnd(width - 10 - suffix.length) + suffix;
|
||||
row.fg = value ? palette.text : palette.muted;
|
||||
const border = this.borders[i]!; border.visible = row.visible; border.top = row.top; border.width = width - 6;
|
||||
border.content = '[' + ' '.repeat(width - 8) + ']'; border.fg = focused ? palette.focus : palette.muted;
|
||||
});
|
||||
this.actions.top = height - 4; this.actions.width = width - 6;
|
||||
this.actions.content = this.message ? ` ${this.submitLabel} \nEnter or Esc close` : ` ${this.submitLabel} \nTab next · Esc cancel · Ctrl-U clear`;
|
||||
this.actions.bg = this.index === this.fields.length ? palette.selected : palette.surface;
|
||||
this.actions.fg = this.index === this.fields.length ? palette.selectedText : palette.text;
|
||||
this.actions.top = height - 4; this.actions.width = this.submitLabel.length;
|
||||
this.actions.content = this.submitLabel; this.actions.fg = palette.text;
|
||||
this.actionBorder.top = height - 4; this.actionBorder.width = this.submitLabel.length + 4;
|
||||
this.actionBorder.content = '[' + ' '.repeat(this.submitLabel.length + 2) + ']';
|
||||
this.actionBorder.fg = this.index === this.fields.length ? palette.focus : palette.muted;
|
||||
this.help.top = height - 3; this.help.width = width - 6;
|
||||
this.help.content = this.message ? 'Enter or Esc close' : 'Tab next · Esc cancel · Ctrl-U clear';
|
||||
}
|
||||
private complete() {
|
||||
const values = Object.fromEntries(this.fields.filter(field => !field.secret).map(field => [field.label, field.value ?? field.choices?.[0] ?? '']));
|
||||
|
||||
@@ -135,3 +135,47 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`Databricks prerequisi
|
||||
assert.equal(f.requests.at(-1)?.action, 'save');
|
||||
} finally { shell.close(); }
|
||||
});
|
||||
|
||||
for (const [width, height] of [[120, 40], [60, 20]]) test(`dialog visual hierarchy and pointer focus at ${width}x${height}`, async () => {
|
||||
const ui = await createTestRenderer({ width, height, exitOnCtrlC: false });
|
||||
const dialog = new ProviderDialog(ui.renderer, 'ADD PROVIDER', [{ label: 'Name', value: 'OpenAI' }, { label: 'API key', secret: true }], () => {});
|
||||
const rgb = (color: { buffer: Uint16Array }) => Array.from(color.buffer).slice(0, 3).join(',');
|
||||
const spans = () => ui.captureSpans().lines.flatMap(line => line.spans);
|
||||
const check = (text: string, color: string) => {
|
||||
const span = spans().find(span => span.text.includes(text));
|
||||
assert.ok(span, text); assert.equal(rgb(span.fg), color); assert.equal(rgb(span.bg), '13,16,14');
|
||||
};
|
||||
const key = (name: string) => dialog.key({ name, sequence: '', ctrl: false, meta: false, shift: false });
|
||||
try {
|
||||
await ui.renderOnce();
|
||||
check('Name', '125,134,124'); check('OpenAI', '220,225,216'); check('[', '255,211,78'); check('Enter API key', '125,134,124');
|
||||
key('tab'); await ui.renderOnce(); check('Enter API key', '125,134,124');
|
||||
const keyboard = ui.captureCharFrame();
|
||||
key('up'); await ui.renderOnce();
|
||||
const lines = ui.captureCharFrame().split('\n'), y = lines.findIndex(line => line.includes('Enter API key'));
|
||||
await ui.mockMouse.click(lines[y]!.indexOf('Enter API key'), y); await ui.renderOnce();
|
||||
assert.equal(ui.captureCharFrame(), keyboard);
|
||||
dialog.updateSecretLength(4); await ui.renderOnce(); check('••••', '220,225,216');
|
||||
key('tab'); await ui.renderOnce(); check('Save', '220,225,216'); check('Tab next', '125,134,124');
|
||||
for (const span of spans()) assert.notEqual(rgb(span.bg), '220,225,216');
|
||||
} finally { dialog.cancel(); ui.renderer.destroy(); }
|
||||
});
|
||||
|
||||
for (const [width, height] of [[120, 40], [60, 20]]) test(`choice arrows and notice actions have separate boundaries at ${width}x${height}`, async () => {
|
||||
const ui = await createTestRenderer({ width, height, exitOnCtrlC: false });
|
||||
let dialog = new ProviderDialog(ui.renderer, 'ADD PROVIDER', [{ label: 'Type', value: 'openai', choices: ['openai', 'anthropic'] }], () => {}, 'Continue');
|
||||
try {
|
||||
await ui.renderOnce();
|
||||
const lines = ui.captureCharFrame().split('\n');
|
||||
assert.doesNotMatch(lines.find(line => line.includes('Type'))!, /←|→/);
|
||||
assert.match(lines.find(line => line.includes('openai'))!, /\[ openai +← → \]/);
|
||||
assert.match(lines.find(line => line.includes('Continue'))!, /\[ Continue +\]/);
|
||||
dialog.cancel();
|
||||
dialog = new ProviderDialog(ui.renderer, 'NOTICE', [], () => {}, 'Close', 'Nothing changed.');
|
||||
await ui.renderOnce();
|
||||
assert.match(ui.captureCharFrame(), /\[ Close +\]/);
|
||||
const help = ui.captureSpans().lines.flatMap(line => line.spans).find(span => span.text.includes('Enter or Esc close'))!;
|
||||
assert.deepEqual(Array.from(help.fg.buffer).slice(0, 3), [125, 134, 124]);
|
||||
assert.deepEqual(Array.from(help.bg.buffer).slice(0, 3), [13, 16, 14]);
|
||||
} finally { dialog.cancel(); ui.renderer.destroy(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user