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