diff --git a/README.md b/README.md
index f6ef587..162827c 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,22 @@ launcher() # interactive menu
Or run individual demos directly: `dashboard()`, `snake()`, `life()`, `sysmon()`, `anim_demo()`, `windows_demo()`, `chart_demo()`, `form_demo()`, `effects_demo()`, and more.
+### In a web browser
+
+Any demo can run in a browser instead of the terminal — the same app, rendered by xterm.js over a WebSocket. This requires the `HTTP.jl` package. Because it's a weak dependency, you must install it first with `] add HTTP`. Pass a backend, or use the `browser` shorthand:
+
+```julia
+using TachikomaDemos, HTTP
+
+run_demo(snake; backend = :webterminal) # opens http://127.0.0.1:8000
+browser(life) # same thing, shorthand
+launcher(backend = :webterminal) # pick from the menu; demos open in the browser
+
+run_demo(fps_demo; backend = :webterminal, port = 9000) # choose the port
+```
+
+`backend = :console` (the default) keeps running in the terminal. The web path is single-session (see DualUIWeb) and needs a Tachikoma with the `io=` sink; it resizes live with the browser window, and pixel panes render as SIXEL graphics through xterm.js's image addon.
+
## Gallery
diff --git a/demos/TachikomaDemos/Project.toml b/demos/TachikomaDemos/Project.toml
index f9e4094..78703fb 100644
--- a/demos/TachikomaDemos/Project.toml
+++ b/demos/TachikomaDemos/Project.toml
@@ -8,6 +8,7 @@ CommonMark = "a80b9123-70ca-4bc0-993e-6e3bcb318db6"
DBInterface = "a10d1c49-ce27-4219-8d33-6db1a4562965"
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
FreeTypeAbstraction = "663a7486-cb36-511b-a19d-713bb74d65c9"
+HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
Match = "7eb4fadd-790c-5f42-8a69-bfa0b872bfbf"
SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9"
Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a"
@@ -15,10 +16,14 @@ Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a"
[sources]
Tachikoma = {path = "../.."}
+[extensions]
+TachikomaDemosHTTPExt = "HTTP"
+
[compat]
ColorTypes = "0.12.1"
CommonMark = "1.0.1"
DBInterface = "2"
FreeTypeAbstraction = "0.10.8"
+HTTP = "2.6.1"
Match = "2.4.1"
SQLite = "1"
diff --git a/demos/TachikomaDemos/ext/TachikomaDemosHTTPExt.jl b/demos/TachikomaDemos/ext/TachikomaDemosHTTPExt.jl
new file mode 100644
index 0000000..2982451
--- /dev/null
+++ b/demos/TachikomaDemos/ext/TachikomaDemosHTTPExt.jl
@@ -0,0 +1,196 @@
+module TachikomaDemosHTTPExt
+
+import TachikomaDemos: _demo_web
+import HTTP
+import Tachikoma
+
+const HTML_PAGE = """
+
+
+
+ Tachikoma Web
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+struct WSIO <: IO
+ ws::HTTP.WebSockets.WebSocket
+end
+Base.write(io::WSIO, b::UInt8) = write(io, [b])
+Base.write(io::WSIO, a::Vector{UInt8}) = (HTTP.WebSockets.send(io.ws, a); length(a))
+Base.write(io::WSIO, s::String) = (HTTP.WebSockets.send(io.ws, s); sizeof(s))
+Base.flush(io::WSIO) = nothing
+Base.isopen(io::WSIO) = HTTP.WebSockets.isopen(io.ws)
+Base.close(io::WSIO) = close(io.ws)
+
+function _demo_web(model::Tachikoma.Model; port::Int = 8000, kwargs...)
+ live_server = Ref{Any}(nothing)
+ app_task = Ref{Task}()
+
+ server = HTTP.listen!("127.0.0.1", port) do http
+ if HTTP.WebSockets.isupgrade(http.message)
+ HTTP.WebSockets.upgrade(http) do ws
+ out_io = WSIO(ws)
+
+ # Input pipe
+ inp = Base.BufferStream()
+
+ live_term = Ref{Any}(nothing)
+
+ app_task[] = Threads.@spawn begin
+ try
+ println("Starting app task for model: ", typeof(model))
+ Tachikoma.app(model; io=out_io, input=inp, tty_size=(rows=24, cols=80), on_terminal = t -> (live_term[] = t), kwargs...)
+ println("App task finished for model: ", typeof(model))
+ catch e
+ e isa InterruptException || @error "App error" exception=(e, catch_backtrace())
+ finally
+ try close(inp) catch end
+ try close(ws) catch end
+ # If the app genuinely wanted to quit (e.g. Esc, or a demo was selected),
+ # close the server so the caller (like `launcher`) can proceed.
+ # Otherwise (e.g. user refreshed page), leave the server running to accept reconnects.
+ try
+ if Base.invokelatest(Tachikoma.should_quit, model)
+ println("Model wants to quit. Closing server.")
+ @async close(live_server[])
+ else
+ println("Model didn't want to quit (connection lost). Server stays up.")
+ end
+ catch e
+ @error "Error in should_quit check" exception=(e, catch_backtrace())
+ end
+ end
+ end
+
+ # Read from websocket
+ try
+ for msg in ws
+ s = String(msg)
+ if startswith(s, "R ")
+ println("Received resize: ", s)
+ parts = split(s, ' ')
+ if length(parts) == 3
+ cols = parse(Int, parts[2])
+ rows = parse(Int, parts[3])
+ if live_term[] !== nothing
+ Tachikoma.set_size!(live_term[], (rows=rows, cols=cols))
+ end
+ end
+ elseif startswith(s, "D")
+ write(inp, s[2:end])
+ # BufferStream doesn't need flush, but we can call it if needed, wait, BufferStream doesn't have flush, wait, yes it does?
+ # flush(inp)
+ end
+ end
+ catch e
+ e isa EOFError || e isa Base.IOError || @error "WS error" exception=(e, catch_backtrace())
+ finally
+ println("Websocket closed.")
+ try close(inp) catch end
+ # Avoid throwto which can deadlock with close(server)
+ # The app task will eventually exit when it reads EOF from inp
+ end
+ end
+ else
+ HTTP.setstatus(http, 200)
+ HTTP.setheader(http, "Content-Type" => "text/html")
+ HTTP.startwrite(http)
+ write(http, HTML_PAGE)
+ end
+ end
+
+ live_server[] = server
+
+ println("Demo in the browser at http://127.0.0.1:$port")
+ println("Ctrl-C to stop.")
+ try
+ wait(server)
+ catch e
+ e isa InterruptException || rethrow()
+ finally
+ close(server)
+ end
+ return nothing
+end
+
+end # module
diff --git a/demos/TachikomaDemos/src/TachikomaDemos.jl b/demos/TachikomaDemos/src/TachikomaDemos.jl
index 0c4e3e8..51ce2ee 100644
--- a/demos/TachikomaDemos/src/TachikomaDemos.jl
+++ b/demos/TachikomaDemos/src/TachikomaDemos.jl
@@ -7,6 +7,7 @@ using SQLite
using DBInterface
@tachikoma_app
+include("run_demo.jl")
include("theme_demo.jl")
include("rain.jl")
include("dashboard.jl")
@@ -48,6 +49,7 @@ include("scroll_demo.jl")
include("launcher.jl")
include("simple_tree_demo.jl")
+export run_demo, browser
export demo, rain, dashboard, life, snake, clock, waves, chaos,
sysmon, anim_demo, mouse_demo, dotwave,
showcase, backend_demo, resize_demo, scrollpane_demo,
diff --git a/demos/TachikomaDemos/src/anim_demo.jl b/demos/TachikomaDemos/src/anim_demo.jl
index f3c37e9..78c7252 100644
--- a/demos/TachikomaDemos/src/anim_demo.jl
+++ b/demos/TachikomaDemos/src/anim_demo.jl
@@ -317,5 +317,5 @@ end
function anim_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(AnimDemoModel(); fps=30)
+ run_demo(AnimDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/ansi_demo.jl b/demos/TachikomaDemos/src/ansi_demo.jl
index 965196a..a085463 100644
--- a/demos/TachikomaDemos/src/ansi_demo.jl
+++ b/demos/TachikomaDemos/src/ansi_demo.jl
@@ -218,5 +218,5 @@ function ansi_demo(; theme_name=nothing)
model.scroll_off.content = model.log_lines
model.tick = 0
- app(model; fps=30)
+ run_demo(model; fps=30)
end
diff --git a/demos/TachikomaDemos/src/async_demo.jl b/demos/TachikomaDemos/src/async_demo.jl
index 26a38b6..59e494a 100644
--- a/demos/TachikomaDemos/src/async_demo.jl
+++ b/demos/TachikomaDemos/src/async_demo.jl
@@ -284,5 +284,5 @@ end
function async_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(AsyncDemoModel(); fps=30)
+ run_demo(AsyncDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/backend_demo.jl b/demos/TachikomaDemos/src/backend_demo.jl
index f609aad..d9f01d6 100644
--- a/demos/TachikomaDemos/src/backend_demo.jl
+++ b/demos/TachikomaDemos/src/backend_demo.jl
@@ -271,5 +271,5 @@ end
function backend_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(BackendDemoModel(); fps=30)
+ run_demo(BackendDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/chaos.jl b/demos/TachikomaDemos/src/chaos.jl
index b5d34a4..326fd6f 100644
--- a/demos/TachikomaDemos/src/chaos.jl
+++ b/demos/TachikomaDemos/src/chaos.jl
@@ -162,5 +162,5 @@ end
function chaos(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(ChaosModel(); fps=30)
+ run_demo(ChaosModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/chart_demo.jl b/demos/TachikomaDemos/src/chart_demo.jl
index 4b90783..74925cf 100644
--- a/demos/TachikomaDemos/src/chart_demo.jl
+++ b/demos/TachikomaDemos/src/chart_demo.jl
@@ -131,5 +131,5 @@ end
function chart_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(ChartModel(); fps=30)
+ run_demo(ChartModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/clado_demo.jl b/demos/TachikomaDemos/src/clado_demo.jl
index 4328157..cb093bd 100644
--- a/demos/TachikomaDemos/src/clado_demo.jl
+++ b/demos/TachikomaDemos/src/clado_demo.jl
@@ -57,5 +57,5 @@ end
function clado_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(CladoDemoModel(); fps=30)
+ run_demo(CladoDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/clock.jl b/demos/TachikomaDemos/src/clock.jl
index aa10138..04b2db7 100644
--- a/demos/TachikomaDemos/src/clock.jl
+++ b/demos/TachikomaDemos/src/clock.jl
@@ -137,5 +137,5 @@ end
function clock(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(ClockModel(); fps=30)
+ run_demo(ClockModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/colortypes_demo.jl b/demos/TachikomaDemos/src/colortypes_demo.jl
index 4984f4c..2c9ade5 100644
--- a/demos/TachikomaDemos/src/colortypes_demo.jl
+++ b/demos/TachikomaDemos/src/colortypes_demo.jl
@@ -153,4 +153,4 @@ function view(m::ColorTypesModel, f::Frame)
end
end
-colortypes_demo() = app(ColorTypesModel(); fps=10)
+colortypes_demo() = run_demo(ColorTypesModel(); fps=10)
diff --git a/demos/TachikomaDemos/src/dashboard.jl b/demos/TachikomaDemos/src/dashboard.jl
index ec566ec..2bfa8b9 100644
--- a/demos/TachikomaDemos/src/dashboard.jl
+++ b/demos/TachikomaDemos/src/dashboard.jl
@@ -214,5 +214,5 @@ end
function dashboard(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(DashboardModel(); fps=30)
+ run_demo(DashboardModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/datatable_demo.jl b/demos/TachikomaDemos/src/datatable_demo.jl
index 6729665..80cd6f7 100644
--- a/demos/TachikomaDemos/src/datatable_demo.jl
+++ b/demos/TachikomaDemos/src/datatable_demo.jl
@@ -157,5 +157,5 @@ end
function datatable_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(DataTableModel(); fps=30)
+ run_demo(DataTableModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/dotwave.jl b/demos/TachikomaDemos/src/dotwave.jl
index 72b669c..71b52c6 100644
--- a/demos/TachikomaDemos/src/dotwave.jl
+++ b/demos/TachikomaDemos/src/dotwave.jl
@@ -64,5 +64,5 @@ end
function dotwave(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(DotWaveModel(); fps=30)
+ run_demo(DotWaveModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/editor_demo.jl b/demos/TachikomaDemos/src/editor_demo.jl
index 92c0d82..370ceb8 100644
--- a/demos/TachikomaDemos/src/editor_demo.jl
+++ b/demos/TachikomaDemos/src/editor_demo.jl
@@ -119,5 +119,5 @@ end
function editor_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(EditorModel(); fps=30)
+ run_demo(EditorModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/effects_demo.jl b/demos/TachikomaDemos/src/effects_demo.jl
index f7b3d22..7d5d279 100644
--- a/demos/TachikomaDemos/src/effects_demo.jl
+++ b/demos/TachikomaDemos/src/effects_demo.jl
@@ -245,5 +245,5 @@ end
function effects_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(EffectsModel(); fps=30)
+ run_demo(EffectsModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/form_demo.jl b/demos/TachikomaDemos/src/form_demo.jl
index d70d664..f609fc2 100644
--- a/demos/TachikomaDemos/src/form_demo.jl
+++ b/demos/TachikomaDemos/src/form_demo.jl
@@ -156,5 +156,5 @@ end
function form_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(FormModel(); fps=30)
+ run_demo(FormModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/fps_demo.jl b/demos/TachikomaDemos/src/fps_demo.jl
index c1e8386..4b3c89a 100644
--- a/demos/TachikomaDemos/src/fps_demo.jl
+++ b/demos/TachikomaDemos/src/fps_demo.jl
@@ -822,7 +822,7 @@ function fps_demo(; theme_name=nothing, fps=60)
model.restart = false
model.quit = false
model.last_time = time()
- app(model; fps=model.target_fps)
+ run_demo(model; fps=model.target_fps)
model.restart || break
end
end
diff --git a/demos/TachikomaDemos/src/launcher.jl b/demos/TachikomaDemos/src/launcher.jl
index 5d7256c..2b9d3ff 100644
--- a/demos/TachikomaDemos/src/launcher.jl
+++ b/demos/TachikomaDemos/src/launcher.jl
@@ -463,11 +463,14 @@ function view(m::LauncherModel, f::Frame)
), footer_area, buf)
end
-function launcher(; theme_name=nothing)
+function launcher(; theme_name=nothing, backend::Symbol=_DEMO_BACKEND[])
theme_name !== nothing && set_theme!(theme_name)
model = LauncherModel()
while true
- result = app(model; fps=30)
+ # The MENU is always a UI -- you pick from it here, and the
+ # chosen demo opens on `backend`. `run_demo` handles routing it
+ # to terminal or webterminal based on the current context.
+ result = run_demo(model; fps=30)
result === :restart && continue
model.launch_idx == 0 && break
# Launch selected demo, return to menu on exit
@@ -476,7 +479,7 @@ function launcher(; theme_name=nothing)
model.launch_idx = 0
model.tick = 0
try
- DEMO_ENTRIES[idx].launch()
+ run_demo(DEMO_ENTRIES[idx].launch; backend=backend)
catch e
e isa InterruptException && rethrow()
@warn "Demo exited with error" exception=(e, catch_backtrace())
diff --git a/demos/TachikomaDemos/src/life.jl b/demos/TachikomaDemos/src/life.jl
index 1634df7..4736424 100644
--- a/demos/TachikomaDemos/src/life.jl
+++ b/demos/TachikomaDemos/src/life.jl
@@ -213,5 +213,5 @@ end
function life(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(LifeModel(); fps=60)
+ run_demo(LifeModel(); fps=60)
end
diff --git a/demos/TachikomaDemos/src/markdown_demo.jl b/demos/TachikomaDemos/src/markdown_demo.jl
index ecb50a1..03803b2 100644
--- a/demos/TachikomaDemos/src/markdown_demo.jl
+++ b/demos/TachikomaDemos/src/markdown_demo.jl
@@ -46,7 +46,7 @@ function view(m::MyModel, f::Frame)
render(Paragraph("Count: \$(m.count)"), f.area, f.buffer)
end
-app(MyModel())
+run_demo(MyModel())
```
## Widget Gallery
@@ -374,5 +374,5 @@ function markdown_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
enable_markdown()
model = MarkdownDemoModel()
- app(model; fps=30)
+ run_demo(model; fps=30)
end
diff --git a/demos/TachikomaDemos/src/mouse_demo.jl b/demos/TachikomaDemos/src/mouse_demo.jl
index 6252051..eff9291 100644
--- a/demos/TachikomaDemos/src/mouse_demo.jl
+++ b/demos/TachikomaDemos/src/mouse_demo.jl
@@ -235,5 +235,5 @@ end
function mouse_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(MouseDemoModel(); fps=30)
+ run_demo(MouseDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/paged_datatable_demo.jl b/demos/TachikomaDemos/src/paged_datatable_demo.jl
index 9c8ae6e..b87f416 100644
--- a/demos/TachikomaDemos/src/paged_datatable_demo.jl
+++ b/demos/TachikomaDemos/src/paged_datatable_demo.jl
@@ -663,5 +663,5 @@ end
function paged_datatable_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(PagedDataTableModel(); fps=30)
+ run_demo(PagedDataTableModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/phylo_demo.jl b/demos/TachikomaDemos/src/phylo_demo.jl
index 6caedf3..2971da7 100644
--- a/demos/TachikomaDemos/src/phylo_demo.jl
+++ b/demos/TachikomaDemos/src/phylo_demo.jl
@@ -57,5 +57,5 @@ end
function phylo_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(PhyloDemoModel(); fps=30)
+ run_demo(PhyloDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/rain.jl b/demos/TachikomaDemos/src/rain.jl
index cbdf26f..af0b405 100644
--- a/demos/TachikomaDemos/src/rain.jl
+++ b/demos/TachikomaDemos/src/rain.jl
@@ -129,5 +129,5 @@ end
function rain(; theme_name=nothing, density=0.4)
theme_name !== nothing && set_theme!(theme_name)
- app(RainModel(density=density); fps=30)
+ run_demo(RainModel(density=density); fps=30)
end
diff --git a/demos/TachikomaDemos/src/repl_demo.jl b/demos/TachikomaDemos/src/repl_demo.jl
index 4e824ee..36b303a 100644
--- a/demos/TachikomaDemos/src/repl_demo.jl
+++ b/demos/TachikomaDemos/src/repl_demo.jl
@@ -191,7 +191,7 @@ function repl_demo(; tty_out=nothing)
while true
model = REPLDemoModel()
result = try
- Tachikoma.app(model; fps=30, tty_out,
+ run_demo(model; fps=30, tty_out,
on_stdout = line -> _route_output(model, line),
on_stderr = line -> _route_output(model, line),
)
diff --git a/demos/TachikomaDemos/src/resize_demo.jl b/demos/TachikomaDemos/src/resize_demo.jl
index 886125d..10f0741 100644
--- a/demos/TachikomaDemos/src/resize_demo.jl
+++ b/demos/TachikomaDemos/src/resize_demo.jl
@@ -207,5 +207,5 @@ end
function resize_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
model = ResizeDemoModel()
- app(model; fps=30)
+ run_demo(model; fps=30)
end
diff --git a/demos/TachikomaDemos/src/run_demo.jl b/demos/TachikomaDemos/src/run_demo.jl
new file mode 100644
index 0000000..a6dcaa7
--- /dev/null
+++ b/demos/TachikomaDemos/src/run_demo.jl
@@ -0,0 +1,90 @@
+# run_demo.jl -- one launcher, two backends.
+#
+# Every demo ends on `app(SomeModel(); fps = n)`. Routing that one call
+# through `run_demo` gives every demo a browser option for free: `:console`
+# hands the model to `app` as before, `:webterminal` serves it over a WebSocket
+# through DualUIWeb, and the demo body never has to know which.
+#
+# The web half lives in a package extension (TachikomaDemosHTTPExt),
+# loaded only when HTTP is present. Until then `:webterminal` fails with a
+# sentence, so the demos still run with nothing but Tachikoma.
+
+"""
+The backend a bare `run_demo(model)` uses when none is passed. Set for the
+duration of a call by the function form of `run_demo` (and by `launcher`);
+a `Ref` rather than a scoped value so the demos stay on Julia 1.10.
+Internal.
+"""
+const _DEMO_BACKEND = Ref(:console)
+
+"""
+The port a `:webterminal` demo binds, set by the function form of `run_demo` so it
+reaches the server without passing through the demo function (which does not
+take a `port`). Internal.
+"""
+const _DEMO_PORT = Ref(8000)
+
+"""
+ _demo_web(model; kwargs...)
+
+Serve `model` in the browser. This is the `::Any` FALLBACK -- the error
+shown when DualUIWeb is not loaded. The extension ADDS a more specific
+`::Model` method (so it never overwrites this one, which precompilation
+forbids), and that method wins whenever DualUIWeb is present. Internal.
+"""
+_demo_web(::Any; kwargs...) = error(
+ "run_demo(...; backend = :webterminal) needs HTTP: `using HTTP`. " *
+ "It serves the demo in the browser over a WebSocket.")
+
+"""
+ run_demo(model::Model; fps = 60, backend = , kwargs...)
+
+Run a demo's model on `backend`: `:console` (the default) hands it to
+[`app`](@ref); `:webterminal` serves it in the browser over HTTP/WebSockets. This is
+the one call every demo ends on, so every demo gains a web option.
+
+The default `backend` is whatever the enclosing `run_demo(demo_function;
+backend = ...)` or `launcher(; backend = ...)` set, so a demo body can just
+write `run_demo(MyModel(); fps = 30)` and inherit the caller's choice.
+"""
+function run_demo(model::Model; fps::Int = 60,
+ backend::Symbol = _DEMO_BACKEND[], kwargs...)
+ backend === :webterminal && return _demo_web(model; port = _DEMO_PORT[], kwargs...)
+ return app(model; fps = fps, kwargs...)
+end
+
+"""
+ run_demo(demo::Function, args...; backend = :console, kwargs...)
+
+Run a demo FUNCTION on `backend`. Sets the backend for the demo's own
+`run_demo(model)` call, invokes it, and restores the previous default.
+
+ run_demo(fps_demo; backend = :webterminal) # in the browser
+ run_demo(fps_demo; backend = :webterminal, port = 9000)
+ run_demo(snake) # in the terminal
+
+`port` is a web option, so it is captured here and handed to the server
+rather than forwarded to `demo` (which takes no `port`). Everything else in
+`args`/`kwargs` goes to `demo`.
+"""
+function run_demo(demo::Function, args...; backend::Symbol = :console,
+ port::Int = _DEMO_PORT[], kwargs...)
+ old_b, old_p = _DEMO_BACKEND[], _DEMO_PORT[]
+ _DEMO_BACKEND[], _DEMO_PORT[] = backend, port
+ try
+ return demo(args...; kwargs...)
+ finally
+ _DEMO_BACKEND[], _DEMO_PORT[] = old_b, old_p
+ end
+end
+
+"""
+ browser(demo, args...; kwargs...)
+
+Shorthand for `run_demo(demo; backend = :webterminal, ...)`: run a demo in the
+browser.
+
+ browser(fps_demo)
+"""
+browser(demo::Function, args...; kwargs...) =
+ run_demo(demo, args...; backend = :webterminal, kwargs...)
diff --git a/demos/TachikomaDemos/src/scroll_demo.jl b/demos/TachikomaDemos/src/scroll_demo.jl
index d64a5eb..4183368 100644
--- a/demos/TachikomaDemos/src/scroll_demo.jl
+++ b/demos/TachikomaDemos/src/scroll_demo.jl
@@ -306,4 +306,4 @@ function view(m::ScrollDemoModel, f::Frame)
), rows[2], buf)
end
-scroll_demo() = app(ScrollDemoModel(); fps=30)
+scroll_demo() = run_demo(ScrollDemoModel(); fps=30)
diff --git a/demos/TachikomaDemos/src/scrollpane_demo.jl b/demos/TachikomaDemos/src/scrollpane_demo.jl
index 1e34093..d75d6b0 100644
--- a/demos/TachikomaDemos/src/scrollpane_demo.jl
+++ b/demos/TachikomaDemos/src/scrollpane_demo.jl
@@ -186,5 +186,5 @@ end
function scrollpane_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
model = ScrollPaneDemoModel()
- app(model; fps=30)
+ run_demo(model; fps=30)
end
diff --git a/demos/TachikomaDemos/src/showcase.jl b/demos/TachikomaDemos/src/showcase.jl
index 14e9f9e..8444252 100644
--- a/demos/TachikomaDemos/src/showcase.jl
+++ b/demos/TachikomaDemos/src/showcase.jl
@@ -49,11 +49,11 @@ end
should_quit(m::ShowcaseModel) = m.quit
function update!(m::ShowcaseModel, evt::KeyEvent)
- if evt.key == :char
+ if evt.key == :char && evt.action == Tachikoma.key_press
evt.char == 'q' && (m.quit = true)
evt.char == 'p' && (m.paused = !m.paused)
end
- evt.key == :escape && (m.quit = true)
+ evt.key == :escape && evt.action == Tachikoma.key_press && (m.quit = true)
end
# ── Rainbow arc renderer ─────────────────────────────────────────────
@@ -386,7 +386,7 @@ function view(m::ShowcaseModel, f::Frame)
SPINNER_DOTS[si], Style(fg=rainbow_color(mod(Float64(tick) * 0.02, 1.0))))
render(StatusBar(
- left=[Span(" [p]pause [Ctrl+T]theme [Ctrl+?]help ",
+ left=[Span(" [p]pause [Ctrl+T]theme [F1]help ",
tstyle(:text_dim))],
right=[Span("[q/Esc]quit ", tstyle(:text_dim))],
), footer_area, buf)
@@ -394,5 +394,5 @@ end
function showcase(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(ShowcaseModel(); fps=30)
+ run_demo(ShowcaseModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/simple_tree_demo.jl b/demos/TachikomaDemos/src/simple_tree_demo.jl
index e57026a..2d47305 100644
--- a/demos/TachikomaDemos/src/simple_tree_demo.jl
+++ b/demos/TachikomaDemos/src/simple_tree_demo.jl
@@ -64,5 +64,5 @@ end
function simple_tree_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- return app(TreeState(); fps=30)
+ return run_demo(TreeState(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/sixel_demo.jl b/demos/TachikomaDemos/src/sixel_demo.jl
index 43a5c38..91a38fc 100644
--- a/demos/TachikomaDemos/src/sixel_demo.jl
+++ b/demos/TachikomaDemos/src/sixel_demo.jl
@@ -304,5 +304,5 @@ end
function sixel_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(SixelDemoModel(); fps=20)
+ run_demo(SixelDemoModel(); fps=20)
end
diff --git a/demos/TachikomaDemos/src/sixel_gallery.jl b/demos/TachikomaDemos/src/sixel_gallery.jl
index b580574..325e9b7 100644
--- a/demos/TachikomaDemos/src/sixel_gallery.jl
+++ b/demos/TachikomaDemos/src/sixel_gallery.jl
@@ -581,5 +581,5 @@ end
function sixel_gallery(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(SixelGalleryModel(); fps=20)
+ run_demo(SixelGalleryModel(); fps=20)
end
diff --git a/demos/TachikomaDemos/src/snake.jl b/demos/TachikomaDemos/src/snake.jl
index 74e491e..7bfe0bb 100644
--- a/demos/TachikomaDemos/src/snake.jl
+++ b/demos/TachikomaDemos/src/snake.jl
@@ -205,5 +205,5 @@ end
function snake(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(SnakeModel(); fps=60)
+ run_demo(SnakeModel(); fps=60)
end
diff --git a/demos/TachikomaDemos/src/sysmon.jl b/demos/TachikomaDemos/src/sysmon.jl
index 3a196ee..4d03e00 100644
--- a/demos/TachikomaDemos/src/sysmon.jl
+++ b/demos/TachikomaDemos/src/sysmon.jl
@@ -304,5 +304,5 @@ end
function sysmon(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(SysmonModel(); fps=30)
+ run_demo(SysmonModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/tabbar_demo.jl b/demos/TachikomaDemos/src/tabbar_demo.jl
index 0466e1b..674fd2c 100644
--- a/demos/TachikomaDemos/src/tabbar_demo.jl
+++ b/demos/TachikomaDemos/src/tabbar_demo.jl
@@ -283,5 +283,5 @@ end
function tabbar_demo(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(TabBarDemoModel(); fps=30)
+ run_demo(TabBarDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/terminal_demo.jl b/demos/TachikomaDemos/src/terminal_demo.jl
index 616d809..2ba04f4 100644
--- a/demos/TachikomaDemos/src/terminal_demo.jl
+++ b/demos/TachikomaDemos/src/terminal_demo.jl
@@ -337,7 +337,7 @@ function terminal_demo(; tty_out=nothing)
while true
model = TerminalDemoModel()
result = try
- Tachikoma.app(model; fps=30, tty_out,
+ run_demo(model; fps=30, tty_out,
on_stdout = line -> _route_output(model, line),
on_stderr = line -> _route_output(model, line),
)
diff --git a/demos/TachikomaDemos/src/theme_demo.jl b/demos/TachikomaDemos/src/theme_demo.jl
index f0d5691..9121173 100644
--- a/demos/TachikomaDemos/src/theme_demo.jl
+++ b/demos/TachikomaDemos/src/theme_demo.jl
@@ -186,5 +186,5 @@ function demo(; theme_name=nothing)
break
end
end
- app(model)
+ run_demo(model)
end
diff --git a/demos/TachikomaDemos/src/unicode_demo.jl b/demos/TachikomaDemos/src/unicode_demo.jl
index adb0ed8..f26981f 100644
--- a/demos/TachikomaDemos/src/unicode_demo.jl
+++ b/demos/TachikomaDemos/src/unicode_demo.jl
@@ -141,5 +141,5 @@ function view(m::UnicodeDemoModel, f::Frame)
end
function unicode_demo()
- app(UnicodeDemoModel(); fps=30)
+ run_demo(UnicodeDemoModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/waves.jl b/demos/TachikomaDemos/src/waves.jl
index 6f30b5a..6da156d 100644
--- a/demos/TachikomaDemos/src/waves.jl
+++ b/demos/TachikomaDemos/src/waves.jl
@@ -173,5 +173,5 @@ end
function waves(; theme_name=nothing)
theme_name !== nothing && set_theme!(theme_name)
- app(WavesModel(); fps=30)
+ run_demo(WavesModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/widget_styles_demo.jl b/demos/TachikomaDemos/src/widget_styles_demo.jl
index d788a98..7e32db6 100644
--- a/demos/TachikomaDemos/src/widget_styles_demo.jl
+++ b/demos/TachikomaDemos/src/widget_styles_demo.jl
@@ -176,5 +176,5 @@ function view(m::WidgetStylesModel, f::Frame)
end
function widget_styles_demo()
- app(WidgetStylesModel(); fps=30)
+ run_demo(WidgetStylesModel(); fps=30)
end
diff --git a/demos/TachikomaDemos/src/windows_demo.jl b/demos/TachikomaDemos/src/windows_demo.jl
index 0ff2489..a3761c7 100644
--- a/demos/TachikomaDemos/src/windows_demo.jl
+++ b/demos/TachikomaDemos/src/windows_demo.jl
@@ -329,7 +329,7 @@ end
# ── Entry point ───────────────────────────────────────────────────────
function windows_demo()
- app(WindowsDemoModel(); fps=30)
+ run_demo(WindowsDemoModel(); fps=30)
end
if abspath(PROGRAM_FILE) == @__FILE__
diff --git a/docs/src/demos.md b/docs/src/demos.md
index 2e36655..b6ac519 100644
--- a/docs/src/demos.md
+++ b/docs/src/demos.md
@@ -15,6 +15,37 @@ launcher()
The launcher presents a categorized tree of all available demos. Use arrow keys to navigate, Left/Right to collapse/expand categories, Enter to launch. Mouse click and scroll wheel are also supported.
+## Running in a browser
+
+Every demo can run in a web browser instead of the terminal. Nothing about
+the demo changes: it still emits the same terminal output, and
+The web backend tunnels that output to an `xterm.js` terminal in the browser
+over a WebSocket, sending keystrokes back. It requires the `HTTP.jl` package.
+Install it via the package manager (`] add HTTP`). Then load `HTTP` and choose a backend:
+
+```julia
+using TachikomaDemos, HTTP
+
+run_demo(snake; backend = :webterminal) # serves http://127.0.0.1:8000
+browser(life) # shorthand for backend = :webterminal
+run_demo(fps_demo; backend = :webterminal, port = 9000)
+
+launcher(backend = :webterminal) # navigate the menu in the terminal,
+ # each demo you pick opens in the browser
+```
+
+`run_demo` is what every demo ends on, so the switch is uniform:
+`backend = :console` (the default) hands the model to [`app`](@ref) in the
+terminal; `backend = :webterminal` serves it over HTTP/WebSockets.
+The web backend lives in a package extension, so it costs nothing and is
+unavailable until `HTTP` is loaded -- `:webterminal` without it fails with a
+message telling you to load it.
+
+One property follows from the web backend: it is
+**single-session** -- one browser at a time. It resizes live with the
+browser window, and pixel panes render as SIXEL graphics through xterm.js's
+image addon, so a demo looks the same in the browser as in the terminal.
+
To run a specific demo directly:
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..21069a3
--- /dev/null
+++ b/justfile
@@ -0,0 +1,21 @@
+# Justfile for TachikomaDemos
+
+# Default task
+default:
+ @just --list
+
+# Run the demo launcher in a local terminal
+terminal:
+ julia --project=demos/TachikomaDemos -e 'using TachikomaDemos; launcher()'
+
+# Run a specific demo in a local terminal (e.g., just run-demo snake)
+run-demo DEMO_NAME:
+ julia --project=demos/TachikomaDemos -e 'using TachikomaDemos; run_demo(TachikomaDemos.{{DEMO_NAME}})'
+
+# Run a specific demo in the web browser (e.g., just web-demo snake)
+web-demo DEMO_NAME PORT="8000":
+ julia --project=demos/TachikomaDemos -e 'using Pkg; Pkg.add("HTTP"); using TachikomaDemos, HTTP; browser(TachikomaDemos.{{DEMO_NAME}}; port={{PORT}})'
+
+# Run the demo launcher in the web browser
+webterminal PORT="8000":
+ julia --project=demos/TachikomaDemos -e 'using Pkg; Pkg.add("HTTP"); using TachikomaDemos, HTTP; browser(launcher; port={{PORT}})'
diff --git a/src/app.jl b/src/app.jl
index 408ce7e..d3e133e 100644
--- a/src/app.jl
+++ b/src/app.jl
@@ -241,8 +241,8 @@ function handle_default_binding!(t::Terminal, overlay::AppOverlay, model::Model,
overlay.show_settings = true
return true
end
- # Ctrl+/ → open help (legacy: byte 0x1f → Char(0x1f + 0x60) = '\x7f')
- if evt.key == :ctrl && evt.char == '\x7f'
+ # Ctrl+/ or F1 → open help (legacy: byte 0x1f → Char(0x1f + 0x60) = '\x7f')
+ if (evt.key == :ctrl && evt.char == '\x7f') || evt.key == :f1
overlay.show_help = true
return true
end
diff --git a/src/events.jl b/src/events.jl
index ea8b108..e4557ad 100644
--- a/src/events.jl
+++ b/src/events.jl
@@ -43,7 +43,7 @@ KeyEvent(c::Char, action::KeyAction) = KeyEvent(:char, c, action)
# repeats as plain bytes instead of CSI u with event_type=2.
# ═══════════════════════════════════════════════════════════════════════
-const _KEYS_DOWN = Set{Tuple{Symbol,Char}}()
+const _KEYS_DOWN = Dict{Tuple{Symbol,Char}, Float64}()
"""
_track_key_state!(evt::KeyEvent) -> KeyEvent
@@ -51,14 +51,19 @@ const _KEYS_DOWN = Set{Tuple{Symbol,Char}}()
Update key-down tracking and reclassify press→repeat when appropriate.
If a key_press arrives for a key already in _KEYS_DOWN (no release seen),
it's a repeat from a terminal sending raw bytes for held keys.
+On legacy terminals without release events, we expire the key after 500ms.
"""
function _track_key_state!(evt::KeyEvent)
id = (evt.key, evt.char)
+ now_t = time()
if evt.action == key_press
- if id in _KEYS_DOWN
- return KeyEvent(evt.key, evt.char, key_repeat)
+ if haskey(_KEYS_DOWN, id)
+ if now_t - _KEYS_DOWN[id] < 0.6
+ _KEYS_DOWN[id] = now_t
+ return KeyEvent(evt.key, evt.char, key_repeat)
+ end
end
- push!(_KEYS_DOWN, id)
+ _KEYS_DOWN[id] = now_t
elseif evt.action == key_release
delete!(_KEYS_DOWN, id)
end