[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

✨ rewrite transitions demo as interactive full-screen menu

Charles Lowell (Apr 22, 2026, 8:59 PM -0500) 53bc7233 83decb43

+262 -64
+262 -64
demo/transitions.ts
··· 1 1 /** 2 - * Transitions demo — a sidebar that smoothly toggles between collapsed and expanded states. 2 + * Interactive transitions demo — a sidebar that smoothly expands and collapses. 3 3 * 4 - * Exercises v1 transitions: one duration, one easing, multiple properties 5 - * (width + bg) on a single element. 4 + * Press Enter to open the menu sidebar, Esc to close it, q or Ctrl+C to quit. 5 + * Exercises v1 transitions: width + bg animated simultaneously. 6 6 */ 7 7 8 - import { main, sleep, until } from "effection"; 8 + import { 9 + createChannel, 10 + each, 11 + ensure, 12 + main, 13 + race, 14 + resource, 15 + sleep, 16 + spawn, 17 + type Stream, 18 + until, 19 + } from "effection"; 9 20 import { 10 21 close, 11 22 createTerm, 12 - cursor, 13 23 fixed, 14 24 grow, 25 + type InputEvent, 26 + type Op, 15 27 open, 28 + percent, 16 29 rgba, 17 - settings, 18 30 text, 19 31 } from "../mod.ts"; 20 - import { alternateBuffer } from "../settings.ts"; 32 + import { alternateBuffer, cursor, settings } from "../settings.ts"; 33 + import { useInput } from "./use-input.ts"; 34 + import { useStdin } from "./use-stdin.ts"; 35 + 36 + const SIDEBAR_BG_OPEN = rgba(80, 80, 140); 37 + const SIDEBAR_BG_CLOSED = rgba(30, 30, 50); 38 + const CONTENT_BG = rgba(18, 18, 22); 39 + const MODELINE_BG = rgba(40, 40, 55); 40 + const TEXT = rgba(220, 220, 220); 41 + const DIM = rgba(130, 130, 150); 42 + const HEADING = rgba(255, 220, 120); 43 + const MENU_ITEM = rgba(180, 200, 240); 44 + const KEY_LABEL = rgba(255, 220, 120); 45 + 46 + const MENU_ITEMS = [ 47 + "New file", 48 + "Open file…", 49 + "Save", 50 + "Save as…", 51 + "—", 52 + "Preferences", 53 + "Quit (q)", 54 + ]; 55 + 56 + const BODY = [ 57 + { kind: "h1", text: "Lorem Ipsum" }, 58 + { 59 + kind: "p", 60 + text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 61 + }, 62 + { 63 + kind: "p", 64 + text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", 65 + }, 66 + { kind: "h2", text: "Section" }, 67 + { 68 + kind: "p", 69 + text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", 70 + }, 71 + { 72 + kind: "p", 73 + text: "Duis aute irure dolor in reprehenderit in voluptate velit esse.", 74 + }, 75 + { 76 + kind: "p", 77 + text: "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui.", 78 + }, 79 + ]; 80 + 81 + interface State { 82 + menuOpen: boolean; 83 + } 84 + 85 + function view(state: State): Op[] { 86 + let ops: Op[] = []; 87 + 88 + ops.push( 89 + open("root", { 90 + layout: { width: grow(), height: grow(), direction: "ttb" }, 91 + }), 92 + ); 93 + 94 + ops.push( 95 + open("main-row", { 96 + layout: { width: grow(), height: grow(), direction: "ltr" }, 97 + }), 98 + ); 99 + 100 + ops.push( 101 + open("sidebar", { 102 + layout: { 103 + width: state.menuOpen ? percent(0.2) : fixed(2), 104 + height: grow(), 105 + direction: "ttb", 106 + padding: { left: 1, right: 1, top: 1, bottom: 1 }, 107 + gap: 1, 108 + }, 109 + bg: state.menuOpen ? SIDEBAR_BG_OPEN : SIDEBAR_BG_CLOSED, 110 + transition: { 111 + duration: 0.25, 112 + easing: "easeInOut", 113 + properties: ["width", "bg"], 114 + }, 115 + }), 116 + ); 117 + 118 + if (state.menuOpen) { 119 + ops.push( 120 + open("menu-title", { layout: { height: fixed(1) } }), 121 + text("Menu", { color: HEADING }), 122 + close(), 123 + ); 124 + for (let item of MENU_ITEMS) { 125 + ops.push( 126 + open(`menu:${item}`, { layout: { height: fixed(1) } }), 127 + text(item, { color: item === "—" ? DIM : MENU_ITEM }), 128 + close(), 129 + ); 130 + } 131 + } 21 132 22 - const BG_COLLAPSED = rgba(30, 30, 60); 23 - const BG_EXPANDED = rgba(80, 80, 140); 24 - const CONTENT_BG = rgba(20, 20, 20); 25 - const TEXT_COLOR = rgba(220, 220, 220); 133 + ops.push(close()); // sidebar 134 + 135 + ops.push( 136 + open("content", { 137 + layout: { 138 + width: grow(), 139 + height: grow(), 140 + direction: "ttb", 141 + padding: { left: 3, right: 3, top: 1, bottom: 1 }, 142 + gap: 1, 143 + }, 144 + bg: CONTENT_BG, 145 + }), 146 + ); 147 + 148 + for (let { kind, text: t } of BODY) { 149 + ops.push(open(`body:${t.slice(0, 8)}`, { layout: { height: fixed(1) } })); 150 + let color = kind === "h1" ? HEADING : kind === "h2" ? KEY_LABEL : TEXT; 151 + ops.push(text(t, { color })); 152 + ops.push(close()); 153 + } 154 + 155 + ops.push(close()); // content 156 + 157 + ops.push(close()); // main-row 158 + 159 + ops.push( 160 + open("modeline", { 161 + layout: { 162 + width: grow(), 163 + height: fixed(1), 164 + direction: "ltr", 165 + padding: { left: 1, right: 1 }, 166 + gap: 2, 167 + }, 168 + bg: MODELINE_BG, 169 + }), 170 + open("mod:quit", { layout: { direction: "ltr", gap: 0 } }), 171 + text("q", { color: KEY_LABEL }), 172 + text(" quit", { color: TEXT }), 173 + close(), 174 + open("mod:menu", { layout: { direction: "ltr", gap: 0 } }), 175 + text("enter", { color: KEY_LABEL }), 176 + text(" show menu", { color: TEXT }), 177 + close(), 178 + open("mod:hide", { layout: { direction: "ltr", gap: 0 } }), 179 + text("esc", { color: KEY_LABEL }), 180 + text(" hide menu", { color: TEXT }), 181 + close(), 182 + close(), // modeline 183 + ); 184 + 185 + ops.push(close()); // root 186 + 187 + return ops; 188 + } 189 + 190 + // A stream that emits at ~60fps intervals, but only while the shared flag is true. 191 + function ticker(flag: { animating: boolean }): Stream<void, void> { 192 + return resource(function* (provide) { 193 + let ch = createChannel<void, void>(); 194 + yield* spawn(function* () { 195 + while (true) { 196 + if (flag.animating) { 197 + yield* sleep(16); 198 + yield* ch.send(); 199 + } else { 200 + // Park until animating becomes true; check every 50ms. 201 + yield* sleep(50); 202 + } 203 + } 204 + }); 205 + let sub = yield* ch; 206 + yield* race([provide(sub), drain(ch)]); 207 + }); 208 + } 209 + 210 + function merge<A, B, TClose>( 211 + a: Stream<A, TClose>, 212 + b: Stream<B, TClose>, 213 + ): Stream<A | B, TClose> { 214 + return resource(function* (provide) { 215 + let sub = { 216 + a: yield* a, 217 + b: yield* b, 218 + }; 219 + return yield* provide({ 220 + *next() { 221 + return yield* race([sub.a.next(), sub.b.next()]); 222 + }, 223 + }); 224 + }); 225 + } 226 + 227 + function* drain<T, TClose>(stream: Stream<T, TClose>) { 228 + for (let _ of yield* each(stream)) { 229 + yield* each.next(); 230 + } 231 + } 26 232 27 233 await main(function* () { 28 - let term = yield* until(createTerm({ width: 60, height: 18 })); 234 + let { columns, rows } = Deno.stdout.isTerminal() 235 + ? Deno.consoleSize() 236 + : { columns: 80, rows: 24 }; 237 + 238 + Deno.stdin.setRaw(true); 239 + yield* ensure(() => Deno.stdin.setRaw(false)); 240 + 241 + let stdin = yield* useStdin(); 242 + let input = useInput(stdin); 243 + 244 + let term = yield* until(createTerm({ width: columns, height: rows })); 245 + 29 246 let tty = settings(alternateBuffer(), cursor(false)); 30 247 Deno.stdout.writeSync(tty.apply); 248 + yield* ensure(() => { 249 + Deno.stdout.writeSync(tty.revert); 250 + }); 31 251 32 - try { 33 - let expanded = false; 34 - let lastToggle = 0; 252 + let state: State = { menuOpen: false }; 253 + let flag = { animating: false }; 254 + 255 + function draw(): void { 256 + let { output, animating } = term.render(view(state)); 257 + flag.animating = animating; 258 + Deno.stdout.writeSync(output); 259 + } 35 260 36 - for (let i = 0; i < 400; i++) { 37 - let wallMs = i * 25; 38 - if (wallMs - lastToggle > 2000) { 39 - expanded = !expanded; 40 - lastToggle = wallMs; 41 - } 261 + draw(); 42 262 43 - let ops = [ 44 - open("root", { 45 - layout: { width: grow(), height: grow(), direction: "ltr" }, 46 - }), 47 - open("sidebar", { 48 - layout: { 49 - width: fixed(expanded ? 24 : 4), 50 - height: grow(), 51 - padding: { left: 1, right: 1, top: 1, bottom: 1 }, 52 - direction: "ttb", 53 - }, 54 - bg: expanded ? BG_EXPANDED : BG_COLLAPSED, 55 - transition: { 56 - duration: 0.4, 57 - easing: "easeInOut", 58 - properties: ["width", "bg"], 59 - }, 60 - }), 61 - open("label", { 62 - layout: { width: grow(), height: fixed(1) }, 63 - }), 64 - text(expanded ? "Menu" : "", { color: TEXT_COLOR }), 65 - close(), 66 - close(), 67 - open("content", { 68 - layout: { 69 - width: grow(), 70 - height: grow(), 71 - padding: { left: 2, right: 2, top: 1, bottom: 1 }, 72 - }, 73 - bg: CONTENT_BG, 74 - }), 75 - open("body", { layout: { width: grow(), height: grow() } }), 76 - text("clayterm transitions demo", { color: TEXT_COLOR }), 77 - close(), 78 - close(), 79 - close(), 80 - ]; 263 + let ticks = ticker(flag); 264 + let events = merge(input, ticks); 81 265 82 - let r = term.render(ops); 83 - Deno.stdout.writeSync(r.output); 84 - yield* sleep(25); 266 + for (let _ of yield* each(events)) { 267 + if (_ !== undefined && typeof _ === "object" && "type" in _) { 268 + let event = _ as InputEvent; 269 + if (event.type === "keydown") { 270 + if (event.ctrl && event.key === "c") { 271 + break; 272 + } 273 + if (event.key === "q") { 274 + break; 275 + } 276 + if (event.key === "Enter") { 277 + state = { ...state, menuOpen: true }; 278 + } 279 + if (event.key === "Escape") { 280 + state = { ...state, menuOpen: false }; 281 + } 282 + } 85 283 } 86 - } finally { 87 - Deno.stdout.writeSync(tty.revert); 284 + draw(); 285 + yield* each.next(); 88 286 } 89 287 });