Important
lettuce is just an early prototype.
more work is yet to be done in terms of features, extensibility, and customizability.
flexible layout ui for web apps
- π https://lettuce.e280.org/ π try it, nerd!
- pane splitting, resizing, vertical, horizontal β you get it
- dude, it's web components β universal compatibility
- you can drag-and-drop tabs between panes
- done efficiently with slots, tab doesn't remount to move
- that's actually legit neato if you have heavy-weight stuff in your tabs
- using
- @e280/sly and lit for ui rendering
- @e280/strata for auto-reactive state management
- @e280/kv for persistence
- #quickstart β full install for lit apps
- #layout β about the layout engine
- #studio β about the ui systems
- #react β react app compatibility
how to setup lettuce in your lit app
- install
npm install @e280/lettuce lit
- html
<lettuce-desk></lettuce-desk>
- css
lettuce-desk { color: #fff8; background: #111; --scale: 1.5em; --gutter-size: 0.7em; --highlight: yellow; --special: aqua; --dropcover: 10%; --warn: red; --warntext: white; --dock: #181818; --taskbar: #181818; --tab: transparent; --tab-active: var(--dock); --gutter: #000; --focal: transparent; --pointerlock: yellow; }
- install shoelace into your html
<head>(sorry)<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/[email protected]/cdn/themes/dark.css" onload="document.documentElement.classList.add('sl-theme-dark');" /> <script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/[email protected]/cdn/shoelace.js" ></script>
- imports
import {html} from "lit" import * as lettuce from "@e280/lettuce"
- setup your panels β these panels are available for the user to open
const {panels, renderer} = lettuce.litSetup({ alpha: { label: "Alpha", icon: () => html`A`, render: () => html`alpha content`, limit: 1, // optional max open instances }, bravo: { label: "Bravo", icon: () => html`B`, render: () => html`bravo content`, }, charlie: { label: "Charlie", icon: () => html`C`, render: () => html`charlie content`, }, })
- setup your layout
const layout = new lettuce.Layout({ stock: lettuce.Builder.fn<keyof typeof panels>()(b => ({ default: () => b.horizontal(1, b.dock(1, "alpha", "bravo", "charlie")), empty: () => b.blank(), })), defaultPanel: "alpha", // optional default panel for new splits })
- panels are referenced by their string keys.
- optional
limitrestricts how many copies of a panel can exist at the same time (default unlimited). once saturated, the adder buttons disable. - optional
defaultPanelopens a default panel on new split docks (pick one that can open another instance). Layoutis a facility for reading and manipulating.Builder.fnhelps you build a tree of layout nodes with less verbosity (note the spooky-typing double-invocation).stock.emptydefines the fallback state for when a user closes everything.stock.defaultdefines the initial state for a first-time user.
- enable localstorage persistence (optional)
const persistence = new lettuce.Persistence({ layout, key: "lettuceLayoutBlueprint", kv: lettuce.Persistence.localStorageKv(), broadcastChannel: new BroadcastChannel("lettuceBroadcast"), }) await persistence.load() persistence.setupAutoSave() persistence.setupLoadOnBroadcast()
- see @e280/kv to learn how to control where the data is saved
- setup a studio for displaying the layout in browser
const studio = new lettuce.Studio({ panels, layout, renderer, // controls - optional })
controlsusesstandardControls(ctx)by default. Override it to render custom taskbar controls (see customize studio).
- register the web components to the dom
studio.ui.registerComponents()
layout engine with serializable state
- import directly to avoid browser concerns (for running under node etc)
import * as lettuce from "@e280/lettuce/layout"
Blueprint- serializable layout data.
- contains a
versionnumber and arootcell.
LayoutNode- any cell, dock, or surface.
- all nodes have a unique string
id. - all nodes have a
kindstring that is "cell", "dock", or "surface".
Cell- a cell is a group that arranges its children either vertically or horizontally.
- this is where splits are expressed.
- a cell's children can be docks or more cells.
Dock- a dock contains the ui with the little tab buttons, splitting buttons, x button, etc.
- a dock's children must be surfaces.
- each dock stores a
taskbarAlignment("top" | "right" | "bottom" | "left") which dictates where its taskbar renders and how the tabs orient themselves.
Surface- a surface is the rendering target location of where a panel will be rendered.
- it uses a
<slot>to magically render your panel into the location of this surface.
π₯ layout explorer.ts β read and query immutable state
- read the source code for the real details
- the state that explorer returns is all immutable and readonly, if you try to mutate it, an error will be thrown
layout.explorer.rootlayout.explorer.walk()layout.explorer.allβ is a "scout"layout.explorer.cellsβ is a "scout"layout.explorer.docksβ is a "scout"layout.explorer.surfacesβ is a "scout"- all scouts have:
.getReport(id).requireReport(id).get(id).require(id).parent(id).reports.nodes.count
π₯ layout actions.ts β mutate state
- read the source code for the real details
- these actions are the only way you can mutate or modify the state
layout.actions.mutate()layout.actions.reset(cell?)layout.actions.addSurface(dockId, panel)layout.actions.activateSurface(surfaceId)layout.actions.setDockActiveSurface(dockId, activeSurfaceIndex)layout.actions.setDockTaskbarAlignment(dockId, alignment)layout.actions.resize(id, size)layout.actions.deleteSurface(id)layout.actions.deleteDock(id)layout.actions.splitDock(id, vertical)layout.actions.moveSurface(id, dockId, destinationIndex)
π₯ layout state management, using strata
- get/set the data
const blueprint = layout.getBlueprint()
layout.setBlueprint(blueprint)
- you can manually subscribe to changes like this
layout.on(blueprint => { console.log("layout changed", blueprint) })
- any strata-compatible ui (like sly) will magically auto-rerender
import {view} from "@e280/sly" view(use => () => html` <p>node count: ${layout.explorer.all.count}</p> `)
- you can use strata effects to magically respond to changes
import {effect} from "@e280/strata" effect(() => { console.log("node count changed", layout.explorer.all.count) })
in-browser layout user-experience
π₯ studio ui.ts β control how the ui is deployed
const studio = new lettuce.Studio({
panels,
layout,
renderer,
controls: context => {
const standard = lettuce.standardControlsParts(context)
return html`
${standard.spawnPanel()}
${standard.closeDock()}
${standard.splitHorizontal()}
${standard.splitVertical()}
// customize non standard taskbar controls as you wish
<button @click=${() => context.meta.studio.layout.actions.reset()}>Reset</button>
// add your own action button
<button @click=${() => someAction()}>whatever</button>
`
},
})- read the source code for the real details
standardControls(ctx)is the default taskbar controls (close, split, alignment, spawn panel, etc.).- import
standardControlsPartsinstead when you need individual controls. lettuce.listPanelsChoices(meta, dock)returns available panels for a dock, including icon, disabled state, and an open() handler.studio.ui.registerComponents()β shortcut to register the components with their default namesstudio.ui.viewsβ access to ui in the form of sly viewsimport {html} from "lit" html` <div> ${studio.ui.views.LettuceDesk()} </div> `
studio.ui.componentsβ access to ui in the form of web componentsimport {dom} from "@e280/sly" // manually registering the web components to the dom dom.register({ // renaming the web component as an example LolDesk: studio.ui.components.LettuceDesk, })
<lol-desk></lol-desk>
lettuce for your react app
- sly-react allows you to turn any sly view into a react component
npm install @e280/sly-react react
- but this time, with jsx render fns
const panels = { alpha: { label: "Alpha", icon: () => html`A`, render: () => <div>alpha content</div>, // π jsx }, }
- note: your icons still have to be lit-html
- and an ordinary layout
const layout = new lettuce.Layout({ stock: lettuce.Builder.fn<keyof typeof panels>()(b => ({ default: () => b.horizontal(1, b.dock(1, "alpha")), empty: () => b.blank(), })), })
- we literally provide sly-react's
reactifyand various react fns toreactIntegrationimport * as lettuce from "@e280/lettuce" import {reactify} from "@e280/sly-react" import {useRef, useState, useEffect, createElement} from "react" const {renderer, makeDeskComponent} = lettuce.reactIntegration({ reactify, useRef, useState, useEffect, createElement, }) const studio = new lettuce.Studio({renderer, panels, layout}) const LettuceDesk = makeDeskComponent(studio)
- lettuce does not depend on react, but accepts react-shaped stuff to perform the integration
- studio requires the
rendererthat the react integration gives you
- now you can use the component
const MyReactComponent = () => { return ( <div> <LettuceDesk render={surface => panels[surface.panel].render()} /> </div> ) }
pay your respects, gimmie a github star.
