Skip to main content

Events

Understanding event handling and propagation in unblessed.

Overview​

unblessed provides a rich event system inspired by DOM events. Widgets emit events for user interactions, state changes, and custom actions. Events can bubble up the widget tree and be captured at any level.

Event System Basics​

Listening to Events​

Use .on() to attach event listeners:

box.on("click", () => {
console.log("Box clicked!");
});

// With event data
list.on("select", (item, index) => {
console.log(`Selected: ${item.getText()} at index ${index}`);
});

// Multiple handlers
box.on("focus", handler1);
box.on("focus", handler2);

Emitting Events​

Emit custom events with .emit():

// Emit event with no data
widget.emit("custom-event");

// Emit event with data
widget.emit("data-loaded", { rows: 100, time: 1234 });

// Emit event that bubbles
widget.emit("action", actionData, true); // true = bubble

Removing Listeners​

Clean up event listeners:

// Remove specific listener
box.off("click", clickHandler);

// Remove all listeners for an event
box.removeAllListeners("click");

// Remove ALL listeners
box.removeAllListeners();

Once Listeners​

Listen to an event only once:

box.once("load", () => {
console.log("Loaded once");
});

// Equivalent to
const handler = () => {
console.log("Loaded once");
box.off("load", handler);
};
box.on("load", handler);

Common Widget Events​

Focus Events​

Fired when focus changes:

box.on("focus", () => {
console.log("Box focused");
box.style.border.fg = "yellow";
screen.render();
});

box.on("blur", () => {
console.log("Box blurred");
box.style.border.fg = "white";
screen.render();
});

Mouse Events​

Mouse interactions:

box.on("click", (data) => {
console.log("Clicked at:", data.x, data.y);
});

box.on("mouse", (data) => {
if (data.action === "mousedown") {
console.log("Mouse down");
} else if (data.action === "mouseup") {
console.log("Mouse up");
} else if (data.action === "mousemove") {
console.log("Mouse move");
}
});

box.on("wheeldown", () => {
console.log("Scroll down");
});

box.on("wheelup", () => {
console.log("Scroll up");
});

Mouse data structure:

interface MouseData {
x: number; // X coordinate
y: number; // Y coordinate
action: "mousedown" | "mouseup" | "mousemove" | "wheeldown" | "wheelup";
button?: "left" | "middle" | "right";
shift?: boolean;
meta?: boolean;
ctrl?: boolean;
}

Keyboard Events​

Key presses on focusable widgets:

textbox.on("keypress", (ch, key) => {
console.log("Character:", ch);
console.log("Key:", key.name);
console.log("Ctrl:", key.ctrl);
console.log("Shift:", key.shift);
});

// Key object structure
interface Key {
name: string; // 'a', 'enter', 'escape', 'up', etc.
ctrl: boolean;
meta: boolean;
shift: boolean;
sequence: string; // Raw escape sequence
full: string; // Full key combo like 'C-a', 'M-x'
}

List Events​

List-specific events:

list.on("select", (item, index) => {
console.log(`Selected item ${index}: ${item.getText()}`);
});

list.on("select item", (item, index) => {
// Alias for 'select'
});

list.on("cancel", () => {
console.log("Selection cancelled (Escape pressed)");
});

list.on("action", (item, index) => {
console.log(`Action on item ${index} (Enter pressed)`);
});

Form Events​

Form submission and validation:

form.on("submit", (data) => {
console.log("Form submitted:", data);
// data = { fieldName: value, ... }
});

form.on("reset", () => {
console.log("Form reset");
});

// Individual field events
textbox.on("submit", (value) => {
console.log("Field submitted:", value);
});

textbox.on("cancel", () => {
console.log("Input cancelled");
});

Scroll Events​

Scrollable widget events:

box.on("scroll", () => {
console.log("Scrolled to:", box.getScroll());
});

box.on("scroll up", () => {
console.log("Scrolled up");
});

box.on("scroll down", () => {
console.log("Scrolled down");
});

Resize Events​

Widget and screen resize:

screen.on("resize", () => {
console.log("Screen resized:", screen.width, screen.height);
// Re-layout widgets if needed
});

box.on("resize", () => {
console.log("Box resized:", box.width, box.height);
});

Show/Hide Events​

Visibility changes:

box.on("show", () => {
console.log("Box shown");
});

box.on("hide", () => {
console.log("Box hidden");
});

Global Key Handlers​

Bind keys globally on the screen:

// Single key
screen.key("q", () => {
process.exit(0);
});

// Multiple keys
screen.key(["q", "C-c"], () => {
process.exit(0);
});

// Key combination
screen.key("C-s", () => {
saveFile();
});

// With event data
screen.key("/", (ch, key) => {
searchBox.focus();
});

Key notation:

  • C-x: Control+X
  • M-x: Alt+X (Meta key)
  • S-x: Shift+X
  • C-M-x: Control+Alt+X
  • Special keys: enter, escape, tab, backspace, delete, up, down, left, right, home, end, pageup, pagedown, f1-f12

Removing Key Handlers​

const handler = () => console.log("Q pressed");
screen.key("q", handler);

// Remove specific handler
screen.unkey("q", handler);

// Remove all handlers for a key
screen.unkey("q");

Event Bubbling​

Events can bubble up the widget tree:

// Parent
const parent = new Box({ parent: screen });

parent.on("custom-event", (data) => {
console.log("Parent received:", data);
});

// Child
const child = new Box({ parent: parent });

// Emit event that bubbles
child.emit("custom-event", { value: 42 }, true);
// Output: "Parent received: { value: 42 }"

Bubbling behavior:

  • Third parameter true enables bubbling
  • Event propagates up parent chain
  • Stops if any handler calls event.stopPropagation()

Stop Propagation​

Prevent bubbling:

child.on("custom-event", (data, event) => {
console.log("Child handled");
event.stopPropagation(); // Don't bubble to parent
});

parent.on("custom-event", (data) => {
console.log("This will not be called");
});

child.emit("custom-event", {}, true);
// Output: "Child handled" only

Custom Events​

Create custom events for your widgets:

export class DataTable extends Table {
loadData(url: string) {
// Emit loading event
this.emit("load-start", { url });

fetch(url)
.then((res) => res.json())
.then((data) => {
this.setData(data);

// Emit success event
this.emit("load-success", { url, rows: data.length });
})
.catch((error) => {
// Emit error event
this.emit("load-error", { url, error: error.message });
});
}
}

// Usage
const table = new DataTable({ parent: screen });

table.on("load-start", ({ url }) => {
statusBar.setContent(`Loading ${url}...`);
});

table.on("load-success", ({ rows }) => {
statusBar.setContent(`Loaded ${rows} rows`);
});

table.on("load-error", ({ error }) => {
statusBar.setContent(`Error: ${error}`);
});

table.loadData("https://api.example.com/data");

Event Patterns​

State Management​

Use events to notify state changes:

class TodoList extends List {
private todos: Todo[] = [];

addTodo(text: string) {
const todo = { id: Date.now(), text, done: false };
this.todos.push(todo);

this.emit("todo-added", todo);
this.emit("todos-changed", this.todos);
}

toggleTodo(id: number) {
const todo = this.todos.find((t) => t.id === id);
if (todo) {
todo.done = !todo.done;
this.emit("todo-toggled", todo);
this.emit("todos-changed", this.todos);
}
}
}

// Usage
todoList.on("todos-changed", (todos) => {
updateStatusBar(todos);
screen.render();
});

Event Delegation​

Handle events from multiple child widgets:

const container = new Box({ parent: screen });

// Create many buttons
for (let i = 0; i < 10; i++) {
const button = new Button({
parent: container,
content: `Button ${i}`,
top: i * 3,
data: { id: i }, // Attach data to widget
});

button.on("press", function () {
// 'this' is the button widget
container.emit("button-pressed", this.data.id);
});
}

// Handle all button presses in one place
container.on("button-pressed", (id) => {
console.log(`Button ${id} pressed`);
});

Promise Events​

Use events with promises:

function submitForm(form: Form): Promise<any> {
return new Promise((resolve, reject) => {
form.once("submit", (data) => {
resolve(data);
});

form.once("cancel", () => {
reject(new Error("Form cancelled"));
});
});
}

// Usage
try {
const data = await submitForm(myForm);
console.log("Submitted:", data);
} catch (error) {
console.log("Cancelled");
}

Event Composition​

Combine multiple events:

function waitForAnyKey(screen: Screen): Promise<Key> {
return new Promise((resolve) => {
const handler = (ch: string, key: Key) => {
screen.off("keypress", handler);
resolve(key);
};
screen.on("keypress", handler);
});
}

// Usage
statusBox.setContent("Press any key to continue...");
await waitForAnyKey(screen);
statusBox.setContent("Continuing...");

Event Debugging​

Log All Events​

Debug event flow:

// Log all events on a widget
const originalEmit = box.emit.bind(box);
box.emit = function (event, ...args) {
console.log("Event:", event, args);
return originalEmit(event, ...args);
};

// Log all key events
screen.on("keypress", (ch, key) => {
console.log("Key:", key.full || key.name);
});

Event Counter​

Track event frequency:

const eventCounts = new Map<string, number>();

function trackEvent(widget: Element, event: string) {
widget.on(event, () => {
const count = (eventCounts.get(event) || 0) + 1;
eventCounts.set(event, count);
console.log(`${event}: ${count} times`);
});
}

trackEvent(box, "click");
trackEvent(box, "focus");

Best Practices​

1. Remove Listeners​

Always clean up:

class MyWidget extends Box {
private intervalId?: NodeJS.Timeout;

start() {
this.intervalId = setInterval(() => {
this.emit("tick");
}, 1000);
}

destroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.removeAllListeners();
super.destroy();
}
}

2. Use Named Functions​

Easier to debug and remove:

// ✅ Good
function handleClick() {
console.log("Clicked");
}
box.on("click", handleClick);
box.off("click", handleClick);

// ❌ Harder to remove
box.on("click", () => console.log("Clicked"));

3. Avoid Memory Leaks​

Don't create circular references:

// ❌ Memory leak
box.on("click", () => {
box.doSomething(); // References box
});

// ✅ Better
function handleClick() {
this.doSomething();
}
box.on("click", handleClick.bind(box));

4. Debounce High-Frequency Events​

let timeout: NodeJS.Timeout;

input.on("keypress", () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
performSearch(input.getValue());
}, 300);
});

Next Steps​