--!strict -- perch.d.luau — THE PERCH LUA API, as Luau type definitions (DEC-0348). -- -- This file is the API. The prelude (src/mods/prelude/*.luau) implements it, -- Perch Studio feeds it to luau-lsp (`luau-lsp lsp --definitions=@perch= -- perch.d.luau`), the MCP serves it (`perch_vocabulary`, part "types") and the -- site publishes it. Every value a mod can touch has a NAMED type here; a -- misspelled field ref, constructor or intent name is a diagnostic in Studio -- before the file is saved, and a load error in perch if it runs anyway. -- -- Dialect: a luau-lsp DEFINITIONS file. Handles are `export type` table types -- (a method is a function field taking `self`); `declare` binds the injected -- globals. Mods open every file with `--!strict`; the runtime compiles the -- annotations away. -- -- Principles: -- * Strings never carry behaviour. Clicks, scrolls, seeks, settings buttons, -- voice intents, hotkeys and channels are functions or typed values. -- * Data binds by FIELD REFERENCE (`f.title`). A widget declares its fields; -- the refs are typed; derived values are functions of the data. -- * Every enum is a table value (`ui.hover.lift`). On the wire it is a string. -- * Async is a callback, always: the callback is the last argument, the call -- returns nothing, a cached answer calls back at once. ------------------------------------------------------------------------------- -- 1. Signals, connections, timers ------------------------------------------------------------------------------- export type connection_t = { disconnect: (self: connection_t) -> (), connected: (self: connection_t) -> boolean, } --- Every `on_*` is a signal_t. `:connect` returns a connection_t; `:once` --- disconnects itself after the first fire. export type signal_t = { connect: (self: signal_t, handler: (T...) -> ()) -> connection_t, once: (self: signal_t, handler: (T...) -> ()) -> connection_t, } export type timer_options_t = { once: boolean? } export type timer_at_options_t = { zone: string? } export type timer_t = { on_fire: signal_t<>, --- Arm (or re-arm: restarts the period). Floors: 250 ms repeating, 16 ms one-shot. --- A `timer.at` handle recomputes its delay from the wall clock here, so --- re-arming it inside its own on_fire fires at the same time the next day. start: (self: timer_t, ms: number?) -> timer_t, stop: (self: timer_t) -> timer_t, running: (self: timer_t) -> boolean, } declare timer: { new: (ms: number, opts: timer_options_t?) -> timer_t, --- A one-shot for a wall-clock time today (or tomorrow if it has passed). --- `:start()` it again from the handler for a daily alarm — the delay is --- recomputed against the wall clock every time. at: (hour: number, minute: number, opts: timer_at_options_t?) -> timer_t, } ------------------------------------------------------------------------------- -- 2. fields_t: the widget's declared data shape ------------------------------------------------------------------------------- -- A widget declares what it pushes. `field.*` builds the schema; the widget -- exposes `fields`, a table of field_ref_t objects with the same keys, which every -- ui.* prop binds to. `w:push` validates against the schema. --- A typed reference to one declared field. condition_t builders start here. export type field_ref_t = { is_true: (self: field_ref_t) -> condition_t, is_false: (self: field_ref_t) -> condition_t, eq: (self: field_ref_t, value: T) -> condition_t, ne: (self: field_ref_t, value: T) -> condition_t, gt: (self: field_ref_t, value: number) -> condition_t, -- field_ref_t lt: (self: field_ref_t, value: number) -> condition_t, -- field_ref_t is_empty: (self: field_ref_t) -> condition_t, -- strings and lists } --- A repeater's list. `ui.list { items = f.apps, item = function(it) … end }` --- hands the builder `it`, the ELEMENT's field refs (`E`). export type list_ref_t = { item: E, is_empty: (self: list_ref_t) -> condition_t, } --- What `field.record { … }` hands out: the record's sub-refs (`f.hands.h`) --- AND a ref to the whole record (`data = f.hands`, `value = f.pill`). Spell a --- record field as `record_ref_t<{ h: field_ref_t, … }>` in your fields_t type. export type record_ref_t = R & field_ref_t --- The clock record a `ui.ticker` in clock mode binds whole. export type clock_record_t = { off: field_ref_t, h24: field_ref_t } --- The chart record a `ui.sparkline` binds whole (`up` picks stroke vs stroke_down). export type chart_record_t = { points: field_ref_t<{ number }>, up: field_ref_t } --- Field constructors. Each returns a schema node; `widget.new` turns the --- schema into field refs. Defaults fill what a partial push omits. declare field: { string: (default: string?) -> field_ref_t, number: (default: number?) -> field_ref_t, boolean: (default: boolean?) -> field_ref_t, --- An image URL, package asset or data URL (`ui.asset("x.png")` values allowed). image: () -> field_ref_t, --- A CSS colour VALUE Lua computed (#hex / hsl() / the monogram gradient form). color: () -> field_ref_t, --- An epoch in milliseconds (tickers). time_ms: () -> field_ref_t, --- An array of numbers (visualizer levels 0..1, sparkline points). numbers: () -> field_ref_t<{ number }>, --- An array of records with their own schema. list: (element: E) -> list_ref_t, --- A nested record; binds dotted (`f.solo.avatar`) or whole (`value = f.pill`). record: (shape: R) -> record_ref_t, } ------------------------------------------------------------------------------- -- 3. Conditions and style rules ------------------------------------------------------------------------------- --- A boolean over the widget's data: `f.on`, `f.on:is_false()`, `f.k:eq("hdr")`. export type condition_t = { both: (self: condition_t, other: condition_t) -> condition_t, -- and either: (self: condition_t, other: condition_t) -> condition_t, -- or negate_: (self: condition_t) -> condition_t, --- Turn the condition into a STYLE RULE: these looks apply while it holds. set: (self: condition_t, looks: style_looks_t) -> style_rule_t, } export type style_rule_t = { __rule: boolean } --- The looks a style rule may flip (never layout). export type style_looks_t = { color: color_t?, bg: background_t?, opacity: number?, weight: number?, ring: ring_t?, glow: glow_t?, gray: boolean?, z: number?, } ------------------------------------------------------------------------------- -- 4. Enums, colours, geometry ------------------------------------------------------------------------------- --- An opaque enum member; equality-comparable. On the wire it IS the string. export type enum_t = { __enum: T } declare surface: { notch_primary: enum_t<"surface">, notch_secondary: enum_t<"surface">, notch_ambient: enum_t<"surface">, page: enum_t<"surface">, transient: enum_t<"surface">, } declare urgency: { ambient: enum_t<"urgency">, event: enum_t<"urgency">, transient: enum_t<"urgency"> } --- A role token (`ui.color.*`), a `"#hex"`/`"#hex8"` literal, or a palette name (`"@brand"`). export type color_t = enum_t<"color"> | string export type linear_gradient_t = { dir: number, stops: { string }, offsets: { number }? } export type radial_gradient_t = { radial: string, stops: { string }, offsets: { number }? } --- A surface role (`ui.bg.*`), a color_t, or a gradient. export type background_t = enum_t<"bg"> | color_t | linear_gradient_t | radial_gradient_t --- `{ width 0..6, color }`: a crisp outline. export type ring_t = { number | color_t } --- `{ blur 0..40, color }`: a soft halo. export type glow_t = { number | color_t } --- A spacing token (`ui.gap.*`) or `{ t, r, b, l }` px. export type pad_t = enum_t<"gap"> | { number } --- `{ t, r, b, l }` px; negatives overlap. export type margin_t = { number } --- Frame children: `{ x, y }` or `{ x, y, w, h }` design px. export type at_t = { number } ------------------------------------------------------------------------------- -- 5. Input: functions, with a context ------------------------------------------------------------------------------- export type mouse_button_t = "left" | "right" | "middle" --- Any widget handle (the untyped face of widget_t). export type any_widget_t = { id: string } --- Delivered to every input handler. export type input_context_t = { --- The widget the node belongs to. widget: any_widget_t, --- Inside ui.list / ui.grid items: the 1-based index and the element's DATA (not refs). index: number?, item: any?, --- ui.bar seek: the 0..1 position (live while dragging, once more on release). frac: number?, --- on_scroll: signed notch count (+ up, - down). steps: number?, --- on_click = "left", on_right = "right", on_middle = "middle"; nil for scroll / seek / change. button: mouse_button_t?, } export type input_handler_t = (ctx: input_context_t) -> () --- `ui.field` edits: the new text and the context. export type change_handler_t = (text: string, ctx: input_context_t) -> () --- Input props every ui.* node may carry. A node with any of these is --- interactive (the island stops being click-through under it); a node with --- only on_scroll does not capture clicks. Scroll fires at most once per 80 ms --- per node; the island's own context menu is suppressed. export type input_props_t = { on_click: input_handler_t?, on_right: input_handler_t?, on_middle: input_handler_t?, on_scroll: input_handler_t?, hover: enum_t<"hover">?, hover_show: boolean?, hover_hide: boolean?, } ------------------------------------------------------------------------------- -- 6. Shared node props ------------------------------------------------------------------------------- --- A bindable value: a literal, a field ref, or a derived function of the --- widget's data (runs in Lua once per push of that widget). export type bind_t = T | field_ref_t | ((data: any) -> T) export type node_props_t = input_props_t & { w: number?, h: number?, min_w: number?, min_h: number?, max_w: number?, max_h: number?, flex: number?, pad: pad_t?, margin: margin_t?, align_self: enum_t<"align">?, at: at_t?, anchor: enum_t<"anchor">?, --- A colour: a token, a literal, a `field.color()` / `field.string()` ref, or a function. color: (bind_t | field_ref_t)?, bg: (bind_t | field_ref_t)?, --- px, or a named radius token ("card", "pill", …). radius: (number | string)?, border: enum_t<"border">?, shadow: enum_t<"shadow">?, ring: ring_t?, glow: glow_t?, opacity: number?, --- Pill-form gate by notch occupancy (`ui.show.single` / `dual`). show: enum_t<"show">?, --- Presence gate: a boolean ref, or a condition_t. when: (field_ref_t | condition_t)?, --- 1..8 style rules that flip looks only. style: { style_rule_t }?, motion: enum_t<"motion">?, --- A static angle in degrees, or a degrees field ref (live clock hands). rotate: bind_t?, pivot: enum_t<"pivot">?, --- A glide anchor KEY so morphs between contexts stay smooth (an identity). flip: string?, } ------------------------------------------------------------------------------- -- 7. The display builders ------------------------------------------------------------------------------- --- A built display node (opaque; the array part of a builder's props holds children). export type node_t = { type: string } export type children_t = { node_t } export type container_props_t = node_props_t & { gap: (enum_t<"gap"> | number)?, align: enum_t<"align">?, justify: enum_t<"justify">? } export type grid_props_t = node_props_t & { cols: number?, gap: number?, row_gap: number?, align: enum_t<"align">?, justify: enum_t<"justify">?, items: list_ref_t?, item: ((it: E) -> node_t)?, max: number? } export type text_props_t = node_props_t & { text: (bind_t | field_ref_t | number)?, role: enum_t<"role">?, prefix: string?, suffix: string?, map: { [string]: string }?, max: number?, size: number?, weight: number?, tracking: number?, lines: number?, align: enum_t<"align">?, line: number?, caps: boolean? } export type icon_props_t = node_props_t & { glyph: (bind_t> | field_ref_t)?, set: string?, size: (number | enum_t<"size">)?, color: bind_t? } export type image_props_t = node_props_t & { src: bind_t?, fit: enum_t<"fit">?, fallback: node_t? } --- One inline vector path (`d` = SVG path data; `fill = "current"` follows the node's colour). export type shape_path_t = { d: string, fill: color_t?, stroke: color_t?, width: number?, cap: ("butt" | "round" | "square")?, join: ("miter" | "round" | "bevel")? } --- `src` = one glyph file; `srcs` + `pick` = a family chosen by a field; `vb` + `paths` = inline data. export type shape_props_t = node_props_t & { src: string?, srcs: { [string]: string }?, pick: field_ref_t?, vb: { number }?, paths: { shape_path_t }?, shapes: { [string]: { shape_path_t } }? } export type bar_props_t = node_props_t & { value: bind_t?, seek: boolean?, on_seek: input_handler_t?, step: number?, hot_at: number?, height: number?, track: color_t?, fill: color_t?, snap: boolean?, knob: boolean? } export type ring_props_t = node_props_t & { value: bind_t?, variant: enum_t<"ring_style">?, size: number?, thickness: number?, color: color_t?, track: color_t?, cap: boolean?, smooth: number? } export type visualizer_props_t = node_props_t & { levels: field_ref_t<{ number }>, bars: number?, bar_w: number?, gap: number?, grow: ("scale" | "height")?, gradient: { string }?, palette: enum_t<"palette">?, floor: number?, radius: number? } --- `points` binds a numbers field, or a chart_record_t (`up` picks stroke vs stroke_down). export type sparkline_props_t = node_props_t & { points: field_ref_t<{ number }> | record_ref_t, stroke: color_t?, stroke_down: color_t?, area: boolean? } --- Clock mode binds a clock_record_t whole (`value = f.pill`); elapsed / countdown bind an epoch-ms field. export type ticker_props_t = node_props_t & { mode: enum_t<"ticker_mode">, value: (record_ref_t | field_ref_t)?, offset: field_ref_t?, h24: field_ref_t?, format: string?, size: number?, weight: number?, role: enum_t<"role">?, caps: boolean? } --- Spread-then-overlap for facepiles: items spread up to `max_gap`, then overlap to fit. export type overlap_t = { item_w: number, max_gap: number? } export type list_props_t = node_props_t & { items: list_ref_t, item: (it: E) -> node_t, max: number?, gap: number?, justify: enum_t<"justify">?, align: enum_t<"align">?, dir: enum_t<"dir">?, overlap: overlap_t?, key: field_ref_t? } --- `hint` names the transport a button is; the view plays the matching optimistic motion on press (the art flip on next / previous, the pause fade on play_pause). export type button_hint_t = "next" | "previous" | "play_pause" export type button_props_t = node_props_t & { glyph: enum_t<"glyph">?, text: string?, hint: button_hint_t?, on_click: input_handler_t } export type field_props_t = node_props_t & { value: bind_t?, multiline: boolean?, placeholder: string?, max: number?, on_change: change_handler_t? } --- A metered canvas; `painter` runs when `data` changes, never per frame. export type draw_props_t = node_props_t & { data: field_ref_t?, painter: paint_t } --- The style table every canvas op takes. export type paint_style_t = { fill: color_t?, stroke: color_t?, width: number?, cap: ("butt" | "round" | "square")?, size: number?, weight: number? } --- The recording canvas a `ui.draw` painter is handed. export type painter_t = { line: (x1: number, y1: number, x2: number, y2: number, style: paint_style_t?) -> (), rect: (x: number, y: number, w: number, h: number, style: paint_style_t?) -> (), circle: (cx: number, cy: number, r: number, style: paint_style_t?) -> (), arc: (cx: number, cy: number, r: number, from: number, to: number, style: paint_style_t?) -> (), --- A polyline: `{ { x, y }, { x, y }, … }` in canvas px. path: (points: { { number } }, style: paint_style_t?) -> (), text: (x: number, y: number, text: string, style: paint_style_t?) -> (), } export type paint_t = (g: painter_t, data: any, w: number, h: number) -> () declare ui: { role: { title: enum_t<"role">, value: enum_t<"role">, h2: enum_t<"role">, huge: enum_t<"role">, big: enum_t<"role">, label: enum_t<"role">, body: enum_t<"role">, meter: enum_t<"role">, sub: enum_t<"role">, unit: enum_t<"role">, caps: enum_t<"role"> }, color: { primary: enum_t<"color">, secondary: enum_t<"color">, half: enum_t<"color">, tertiary: enum_t<"color">, accent: enum_t<"color">, accent_warm: enum_t<"color">, live: enum_t<"color">, red: enum_t<"color">, fill: enum_t<"color">, island: enum_t<"color">, well: enum_t<"color">, current: enum_t<"color"> }, bg: { none: enum_t<"bg">, recessed: enum_t<"bg">, raised: enum_t<"bg">, track: enum_t<"bg">, well: enum_t<"bg">, deep: enum_t<"bg"> }, gap: { xs: enum_t<"gap">, s: enum_t<"gap">, m: enum_t<"gap">, l: enum_t<"gap"> }, size: { xs: enum_t<"size">, s: enum_t<"size">, m: enum_t<"size">, l: enum_t<"size"> }, align: { start: enum_t<"align">, center: enum_t<"align">, finish: enum_t<"align">, baseline: enum_t<"align">, stretch: enum_t<"align"> }, justify: { start: enum_t<"justify">, center: enum_t<"justify">, finish: enum_t<"justify">, between: enum_t<"justify">, evenly: enum_t<"justify"> }, fit: { cover: enum_t<"fit">, contain: enum_t<"fit"> }, border: { none: enum_t<"border">, rim: enum_t<"border">, rim_strong: enum_t<"border"> }, shadow: { none: enum_t<"shadow">, float: enum_t<"shadow">, recess: enum_t<"shadow">, soft: enum_t<"shadow"> }, hover: { lift: enum_t<"hover">, soft: enum_t<"hover">, scope: enum_t<"hover"> }, show: { single: enum_t<"show">, dual: enum_t<"show"> }, motion: { halo: enum_t<"motion">, nod: enum_t<"motion">, pulse: enum_t<"motion">, spin: enum_t<"motion"> }, pivot: { center: enum_t<"pivot">, bottom: enum_t<"pivot"> }, anchor: { center: enum_t<"anchor"> }, envelope: { tall: enum_t<"envelope">, wide: enum_t<"envelope"> }, palette: { theme: enum_t<"palette">, art: enum_t<"palette"> }, dir: { row: enum_t<"dir">, col: enum_t<"dir"> }, ticker_mode: { clock: enum_t<"ticker_mode">, elapsed: enum_t<"ticker_mode">, countdown: enum_t<"ticker_mode"> }, ring_style: { conic: enum_t<"ring_style">, stroke: enum_t<"ring_style"> }, --- Generated from Phosphor: ui.glyph.gear, ui.glyph.skip_forward, … glyph: { [string]: enum_t<"glyph"> }, --- A package-relative image (png/jpg/webp/svg, 600 KB max) as a data value. asset: (path: string) -> string, --- Start a condition from a field ref (sugar for `ref:is_true()`). when: (ref: field_ref_t) -> condition_t, -- containers (the array part of the props table is the children) column: (props: container_props_t & children_t) -> node_t, row: (props: container_props_t & children_t) -> node_t, box: (props: container_props_t & children_t) -> node_t, frame: (props: node_props_t & children_t) -> node_t, scroll: (props: container_props_t & children_t) -> node_t, spacer: (props: node_props_t?) -> node_t, --- Fixed children, OR a repeater (`items` + `item`). Not both. grid: (props: grid_props_t & children_t) -> node_t, -- text and pictures text: (props: text_props_t) -> node_t, icon: (props: icon_props_t) -> node_t, image: (props: image_props_t & children_t) -> node_t, shape: (props: shape_props_t) -> node_t, -- meters bar: (props: bar_props_t) -> node_t, ring: (props: ring_props_t & children_t) -> node_t, visualizer: (props: visualizer_props_t) -> node_t, gauge: (props: node_props_t?) -> node_t, sparkline: (props: sparkline_props_t) -> node_t, -- time ticker: (props: ticker_props_t) -> node_t, -- repeaters list: (props: list_props_t) -> node_t, -- interaction button: (props: button_props_t & children_t) -> node_t, field: (props: field_props_t) -> node_t, draw: (props: draw_props_t) -> node_t, -- shorthands label: (text: bind_t, opts: node_props_t?) -> node_t, value: (text: bind_t, opts: node_props_t?) -> node_t, } ------------------------------------------------------------------------------- -- 8. Widgets ------------------------------------------------------------------------------- export type place_options_t = { --- transient: self-retract after this long; 0 = the mod owns retraction. timeout_ms: number?, --- notch_secondary: the LEADER widget this chip seats beside. with: any_widget_t?, envelope: enum_t<"envelope">?, } --- The table `widget.new` takes. `fields` is required: without a schema nothing binds. export type widget_def_t = { id: string, label: string?, urgency: enum_t<"urgency">?, fields: F } --- `F` is the fields table (typed refs); `push`/`present`/`preview` take the plain --- data table with the same keys (validated at the call: an undeclared key or a --- wrong type raises, naming the widget and the key). export type widget_t = { id: string, fields: F, --- Fires (visible) as this widget's page shows or hides; throttle polls with it. on_visibility: signal_t, place: (self: widget_t, where: enum_t<"surface">, tree: node_t | ((f: F) -> node_t), opts: place_options_t?) -> widget_t, --- Static demo data for the Layout gallery. Declare it BEFORE register(). preview: (self: widget_t, data: any) -> widget_t, --- Upsert with the engine; nothing reaches perch before this. register: (self: widget_t) -> widget_t, --- Presence on, with data. present: (self: widget_t, data: any?) -> widget_t, --- Merge fields into the live data. push: (self: widget_t, data: any) -> widget_t, --- Presence off; placements survive. retire: (self: widget_t) -> widget_t, relabel: (self: widget_t, label: string) -> widget_t, --- Gone entirely; page slots heal. remove: (self: widget_t) -> (), } declare widget: { new: (def: widget_def_t) -> widget_t, } ------------------------------------------------------------------------------- -- 9. Settings: rows are objects you drive ------------------------------------------------------------------------------- export type row_t = { set_visible: (self: row_t, visible: boolean) -> row_t } export type toggle_row_t = row_t export type slider_row_t = row_t export type choice_row_t = row_t export type text_row_t = row_t export type game_row_t = row_t export type launch_row_t = row_t export type timezone_row_t = row_t export type ticker_row_t = row_t export type list_row_t = row_t export type note_row_t = row_t export type status_row_t = { set_visible: (self: status_row_t, visible: boolean) -> status_row_t, --- Live text + the green/red state. set: (self: status_row_t, text: string, ok: boolean) -> status_row_t, } export type button_row_t = { set_visible: (self: button_row_t, visible: boolean) -> button_row_t, set_text: (self: button_row_t, text: string) -> button_row_t, set_enabled: (self: button_row_t, enabled: boolean) -> button_row_t, } --- A note's chip: opens a URL (https only) or copies text to the clipboard. export type link_t = { label: string, url: string } | { label: string, copy: string } --- Options every kind accepts: `inline_with_previous` joins the previous row. export type row_options_t = { inline_with_previous: boolean? } export type toggle_options_t = row_options_t & { default: boolean? } export type slider_options_t = row_options_t & { min: number, max: number, step: number?, default: number? } export type choice_options_t = row_options_t & { options: { string }, default: string? } export type text_options_t = row_options_t & { default: string?, placeholder: string? } --- `options` restricts the picker to some of "steam" | "url" | "app". export type launch_options_t = row_options_t & { options: { string }? } export type ticker_options_t = row_options_t & { default: string? } --- `item` is a table of row constructors keyed like `declare` itself (one level deep). export type list_options_t = row_options_t & { item: { [string]: row_t }, add_label: string?, max: number? } --- `step` renders a numbered card; `image` a package-relative screenshot (600 KB max). export type note_options_t = row_options_t & { text: string, links: { link_t }?, step: number?, image: string? } --- What a `settings.launch` row stores. export type launch_target_t = { type: "steam" | "url" | "app", ref: string, label: string, icon: string } declare settings: { toggle: (label: string, opts: toggle_options_t?) -> toggle_row_t, slider: (label: string, opts: slider_options_t) -> slider_row_t, choice: (label: string, opts: choice_options_t) -> choice_row_t, text: (label: string, opts: text_options_t?) -> text_row_t, --- A Steam appid picked from the library / store search ("" = none). game: (label: string, opts: row_options_t?) -> game_row_t, --- Something to open: a game, a website or an installed app (a launch_target_t). launch: (label: string, opts: launch_options_t?) -> launch_row_t, --- An IANA zone name ("" = none). timezone: (label: string, opts: row_options_t?) -> timezone_row_t, --- A stock, index, ETF or coin (Yahoo symbol, or `crypto:`). ticker: (label: string, opts: ticker_options_t?) -> ticker_row_t, list: (label: string, opts: list_options_t) -> list_row_t, status: (label: string) -> status_row_t, button: (label: string, text: string, on_click: () -> ()) -> button_row_t, note: (label: string, opts: note_options_t) -> note_row_t, } ------------------------------------------------------------------------------- -- 10. Async: callbacks, always ------------------------------------------------------------------------------- export type http_response_t = { status: number, body: string } export type http_options_t = { ua: string?, content_type: string?, timeout_ms: number? } --- res = { status, body } on a 2xx/3xx answer, else nil + err ("http 404", "scope", "rate", "unavailable"). export type http_callback_t = (res: http_response_t?, err: string?) -> () export type websocket_options_t = { headers: { [string]: string }? } export type socket_t = { on_message: signal_t, --- Also fires for a FAILED connect: one closing path. on_close: signal_t, send: (self: socket_t, text: string) -> boolean, close: (self: socket_t) -> (), connected: (self: socket_t) -> boolean, } --- Steam answers: the callback's argument and the matching `on_*` signal's payload. --- `appid` travels as a digit STRING in every Steam answer (the store speaks strings). export type steam_app_t = { appid: string, name: string, installed: boolean? } --- `store` = the store knows the app; `installed` = it is in this machine's library; `header` = the wide header art URL. export type steam_app_info_t = { appid: string, name: string, icon: string, header: string, installed: boolean?, store: boolean } export type steam_artwork_t = { appid: string, icon: string, portrait: string, header: string, capsule: string, hero: string, background: string } export type steam_store_details_t = { appid: string, name: string, description: string, developers: { string }, publishers: { string }, genres: { string }, released: string, coming_soon: boolean, free: boolean, price: string, discount: number, metacritic: number } --- `date` is a UNIX epoch in seconds; `feed` the feed label. export type steam_news_item_t = { title: string, url: string, author: string, contents: string, feed: string, date: number } export type steam_news_t = { appid: string, items: { steam_news_item_t } } export type steam_player_count_t = { appid: string, count: number, at: number } export type steam_player_history_t = { appid: string, points: { number }, at: { number } } export type steam_search_result_t = { appid: string, name: string, icon: string } export type steam_search_t = { term: string, results: { steam_search_result_t } } export type game_change_t = { running: boolean, appid: number, name: string } ------------------------------------------------------------------------------- -- 11. Typed values: intents, hotkeys, channels ------------------------------------------------------------------------------- --- The voice intents perch's router recognises. A mod hooks the ones it serves. export type intent_t = "play_pause" | "next" | "previous" | "restart" | "volume_up" | "volume_down" | "set_volume" | "toggle_mute" | "toggle_deafen" | "hangup" | "accept_call" | "decline_call" | "pomodoro_start_pause" | "pomodoro_reset" --- What an intent handler receives (`set_volume` carries `level` 0..100). export type intent_args_t = { level: number? } --- A global shortcut. `key` is one of the `key.*` enum members. export type hotkey_t = { ctrl: boolean?, shift: boolean?, alt: boolean?, win: boolean?, key: enum_t<"key"> } declare key: { [string]: enum_t<"key"> } -- key.P, key.F4, key.SPACE, … export type hotkey_handle_t = { on_press: signal_t<>, release: (self: hotkey_handle_t) -> (), } --- Mod-to-mod messaging by channel object. export type channel_t = { on_message: signal_t, -- (payload, from_mod_id) publish: (self: channel_t, payload: T) -> (), } ------------------------------------------------------------------------------- -- 12. Provider payloads (what the perch.* signals deliver) ------------------------------------------------------------------------------- export type civil_time_t = { year: number, mon: number, day: number, hour: number, min: number, sec: number, dow: number, off: number, error: string? } export type instant_t = civil_time_t & { epoch: number } export type locale_t = { language: string, region: string, celsius: boolean, hour24: boolean } export type app_entry_t = { name: string, sub: string?, icon: string?, ref: string } export type app_lists_t = { recent: { app_entry_t }, top: { app_entry_t } } export type focused_app_t = { app_id: string, name: string, ref: string } export type apps_options_t = { tracking: boolean?, exclude: { { target: launch_target_t? } }? } --- `dur` / `pos` are SECONDS; an art-only event carries `art_url` alone. export type media_state_t = { has_player: boolean?, playing: boolean?, title: string?, artist: string?, art_url: string?, dur: number?, pos: number? } --- One call participant (`faces[]` and `solo`). export type discord_face_t = { name: string, initial: string, avatar: string, hue_css: string, speaking: boolean, streaming: boolean, muted: boolean, deafened: boolean } --- Fires on call EDGES (join / leave); live speaking state streams natively to the view. export type discord_call_t = { in_call: boolean, started: number?, dm: boolean, server: string, server_icon: string, channel: string, self_mute: boolean, self_deaf: boolean, names: { string }, avatars: { string }, speaking: { number }, streaming: { number }, video: { number }, muted: { number }, deafened: { number }, count: number, faces: { discord_face_t }, has_faces: boolean, solo: discord_face_t? } export type discord_notification_t = { from: string, text: string, avatar: string, context: string, at: number } export type discord_ring_t = { active: boolean, from: string, avatar: string } export type discord_state_t = { connected: boolean, needs_auth: boolean, needs_setup: boolean, disconnected: boolean? } export type discord_options_t = { app_id: string? } export type weather_day_t = { d: string, i: string, t: string, today: boolean } export type weather_state_t = { city: string, temp: string, cond: string, icon: string, hi: string, lo: string, week: { weather_day_t } } --- `unit` is "celsius" or "fahrenheit" (the settings.choice value). export type weather_options_t = { unit: string? } export type volume_state_t = { level: number, muted: boolean } export type brightness_state_t = { level: number } export type night_light_state_t = { on: boolean } export type connectivity_event_t = { kind: "wifi" | "bt" | "usb", name: string, connected: boolean, pct: number } export type timer_set_t = { name: string, mmss: string } export type timer_tick_t = { name: string, secs: number } export type timer_done_t = { name: string } export type power_event_t = { battery: boolean, name: string, pct: number } export type download_progress_t = { name: string, frac: number, active: boolean, bytes: number, rate: number } export type system_notification_t = { app: string, title: string, body: string, icon: string } export type notifications_options_t = { mirror: boolean? } export type performance_t = { cpu: number, gpu: number, ram: number, cput: number, gput: number } export type power_t = { has_battery: boolean, percent: number, charging: boolean, plugged_in: boolean, saver: boolean, seconds_left: number } export type peripheral_t = { name: string, percent: number } export type session_t = { name: string, process_id: number, volume: number, muted: boolean, active: boolean } export type microphone_t = { in_use: boolean, apps: { string } } export type spectrum_t = { bands: { number }, live: boolean } ------------------------------------------------------------------------------- -- 13. perch.* ------------------------------------------------------------------------------- declare perch: { on_start: signal_t<>, on_stop: signal_t<>, --- Fires (values) at boot and on every change; the keys are your declared setting keys. on_configure: signal_t, log: (message: string) -> (), json: { encode: (value: any) -> string, decode: (text: string) -> any }, time: { now: (zone: string?) -> civil_time_t, zone: () -> string, format: (spec: string, zone: string?) -> string, at: (epoch: number, zone: string?) -> instant_t, parse: (text: string) -> number?, }, storage: { get: (key: string) -> any, set: (key: string, value: any) -> (), list: () -> { string }, remove: (key: string) -> (), clear: () -> () }, hash: { sha256: (text: string) -> string, md5: (text: string) -> string }, base64: { encode: (text: string) -> string, decode: (text: string) -> string }, uuid: { generate: () -> string }, locale: { read: () -> locale_t }, text: { number: (value: number | string, separator: string?) -> string, truncate: (text: string, limit: number, ellipsis: string?) -> string }, settings: { --- Declare (or replace) this mod's rows; returns the same keys as row objects. declare: (rows: R) -> R, }, voice: { on_intent: (intent: intent_t, handler: (args: intent_args_t) -> ()) -> connection_t, }, net: { http: { --- get(url, opts?, cb): a function where `opts` would be is taken as the callback. Returns nothing. get: (url: string, opts: (http_options_t | http_callback_t)?, callback: http_callback_t?) -> (), post: (url: string, body: string, opts: (http_options_t | http_callback_t)?, callback: http_callback_t?) -> (), }, websocket: { connect: (url: string, opts: websocket_options_t?) -> socket_t }, }, media: { read: () -> media_state_t, on_change: signal_t, play_pause: () -> (), next: () -> (), previous: () -> (), seek: (microseconds: number) -> (), refresh: () -> (), open_player: () -> (), }, discord: { read: () -> discord_state_t, on_call: signal_t, on_notification: signal_t, on_ring: signal_t, on_status: signal_t, toggle_mute: () -> (), toggle_deafen: () -> (), hangup: () -> (), accept_ring: () -> (), decline_ring: () -> (), connect: () -> (), disconnect: () -> (), configure: (options: discord_options_t) -> (), }, weather: { read: () -> weather_state_t, on_change: signal_t, configure: (options: weather_options_t) -> () }, steam: { installed: () -> { steam_app_t }, running: () -> steam_app_t?, --- Every lookup takes the appid as a string of digits and its callback LAST, and returns nothing; a cached answer calls back at once. app_info: (appid: string, cb: (steam_app_info_t) -> ()) -> (), artwork: (appid: string, cb: (steam_artwork_t) -> ()) -> (), store_details: (appid: string, cb: (steam_store_details_t) -> ()) -> (), news: (appid: string, count: number?, cb: (steam_news_t) -> ()) -> (), player_count: (appid: string, cb: (steam_player_count_t) -> ()) -> (), player_history: (appid: string, cb: (steam_player_history_t) -> ()) -> (), --- Store search. The term is normalised (lowercased, capped at 64 bytes) --- and the answer carries the normalised term. A term under 2 characters --- — or a search inside the engine's 1 s floor — is DROPPED: the callback --- does not fire, so drive it from a debounce, not a keystroke. search: (term: string, cb: (steam_search_t) -> ()) -> (), --- One signal per landing, for answers the engine pushes on its own (the hourly history refresh). on_app_info: signal_t, on_artwork: signal_t, on_store_details: signal_t, on_news: signal_t, on_player_count: signal_t, on_player_history: signal_t, on_search: signal_t, on_game_change: signal_t, }, system: { performance: { read: () -> performance_t }, power: { read: () -> power_t, on_change: signal_t }, peripherals: { list: () -> { peripheral_t } }, clipboard: { read: () -> string, on_change: signal_t }, display: { on_brightness_change: signal_t, on_night_light_change: signal_t }, connectivity: { on_change: signal_t }, timers: { on_set: signal_t, on_done: signal_t, on_cancel: signal_t<>, on_tick: signal_t }, downloads: { on_progress: signal_t }, notifications: { configure: (options: notifications_options_t) -> (), on_notification: signal_t }, }, audio: { volume: { up: () -> (), down: () -> (), set: (level: number) -> (), on_change: signal_t }, sessions: () -> { session_t }, set_session_volume: (process_id: number, level: number) -> boolean, set_session_muted: (process_id: number, muted: boolean) -> boolean, microphone: { read: () -> microphone_t }, spectrum: (bands: number?) -> spectrum_t, play: (file: string) -> (), }, apps: { read: () -> app_lists_t, --- Launch an entry you got from read(). launch: (entry: app_entry_t) -> (), focused: () -> focused_app_t?, on_focus_change: signal_t, configure: (options: apps_options_t) -> (), }, --- `steam` takes the appid as a string of digits (what a launch_target_t / settings.game row stores). open: { url: (url: string) -> (), steam: (appid: string) -> (), app: (ref: string) -> () }, mods: { channel: (name: string) -> channel_t, }, input: { hotkeys: { register: (combo: hotkey_t) -> hotkey_handle_t? }, }, } ------------------------------------------------------------------------------- -- 14. A mod, in full (the strict-typed shape every builtin follows) ------------------------------------------------------------------------------- --[[ --!strict -- displays.luau export type fields_t = { has_player: field_ref_t, playing: field_ref_t, title: field_ref_t, artist: field_ref_t, art: field_ref_t, pos_frac: field_ref_t, bars: field_ref_t<{ number }>, } export type act_t = { previous: () -> (), play_pause: () -> (), next: () -> (), seek: (frac: number) -> () } return { card = function(f: fields_t, act: act_t): node_t return ui.row { gap = 14, when = f.has_player, ui.image { src = f.art, w = 100, h = 100, radius = 20, fallback = ui.icon { glyph = ui.glyph.music_note } }, ui.column { flex = 1, gap = 6, ui.text { text = f.title, role = ui.role.title }, ui.text { text = f.artist, role = ui.role.sub, style = { f.playing:is_false():set { opacity = 0.5 } } }, ui.bar { value = f.pos_frac, seek = true, knob = true, on_seek = function(ctx: input_context_t) act.seek(ctx.frac or 0) end }, ui.row { justify = ui.justify.center, gap = 10, ui.button { glyph = ui.glyph.skip_back, on_click = function() act.previous() end }, ui.button { glyph = ui.glyph.pause, on_click = function() act.play_pause() end }, ui.button { glyph = ui.glyph.skip_forward, on_click = function() act.next() end }, }, }, ui.visualizer { levels = f.bars, bars = 6, palette = ui.palette.art }, } end, } --!strict -- main.luau local displays = require("displays") type settings_t = { popups: boolean? } local act: displays.act_t = { previous = perch.media.previous, play_pause = perch.media.play_pause, next = perch.media.next, seek = function(frac: number) end } local media = widget.new { id = "media", label = "Media", urgency = urgency.ambient, fields = { has_player = field.boolean(false), playing = field.boolean(false), title = field.string(""), artist = field.string(""), art = field.image(), pos_frac = field.number(0), bars = field.numbers(), }, } media:preview { has_player = true, playing = true, title = "Starboy", artist = "The Weeknd", art = ui.asset("preview/cover.png"), pos_frac = 0.29, bars = { 0.5, 0.9, 0.4, 0.8, 0.5, 0.7 } } media:place(surface.page, function(f: displays.fields_t): node_t return displays.card(f, act) end) media:register() perch.media.on_change:connect(function(m: media_state_t) media:push({ has_player = m.has_player, playing = m.playing, title = m.title, artist = m.artist, art = m.art_url }) end) perch.voice.on_intent("play_pause", function() perch.media.play_pause() end) local rows = perch.settings.declare { popups = settings.toggle("Notification popups", { default = true }), link = settings.status("Discord account"), connect = settings.button("Connection", "Connect", function() perch.discord.connect() end), } perch.discord.on_status:connect(function(s: discord_state_t) rows.link:set(if s.connected then "Connected" else "Not connected", s.connected) rows.connect:set_visible(not s.connected) end) perch.on_configure:connect(function(v: settings_t) popups_on = v.popups ~= false end) ]]