Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<table>
Expand Down
5 changes: 5 additions & 0 deletions demos/TachikomaDemos/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@ 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"

[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"
196 changes: 196 additions & 0 deletions demos/TachikomaDemos/ext/TachikomaDemosHTTPExt.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
module TachikomaDemosHTTPExt

import TachikomaDemos: _demo_web
import HTTP
import Tachikoma

const HTML_PAGE = """
<!DOCTYPE html>
<html>
<head>
<title>Tachikoma Web</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-image/lib/xterm-addon-image.js"></script>
<style>
body { margin: 0; padding: 0; background-color: black; height: 100vh; overflow: hidden; }
#terminal-container { width: 100%; height: 100%; }
.xterm-viewport { overflow: hidden !important; }
::-webkit-scrollbar { display: none; }
</style>
</head>
<body>
<div id="terminal-container"></div>
<script>
const term = new Terminal({
cursorBlink: true,
macOptionIsMeta: true,
scrollback: 0
});
const fitAddon = new FitAddon.FitAddon();
const imageAddon = new ImageAddon.ImageAddon();
term.loadAddon(fitAddon);
term.loadAddon(imageAddon);
term.open(document.getElementById('terminal-container'));
fitAddon.fit();

let ws;
function connect() {
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = wsProtocol + '//' + location.host + '/ws';
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';

ws.onopen = () => {
term.reset();
ws.send("R " + term.cols + " " + term.rows);
// Only attach handlers once
if (!window._termHandlersAttached) {
term.onData(data => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send("D" + data);
}
});
term.attachCustomKeyEventHandler(e => {
const isHelp = e.key === '?' || e.key === '/' || (e.shiftKey && e.code === 'Comma') || e.code === 'Slash';
if ((e.ctrlKey || e.metaKey) && isHelp) {
if (e.type === 'keydown') {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send("D\x1f");
}
}
return false;
}
return true;
});
window.addEventListener('resize', () => {
fitAddon.fit();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send("R " + term.cols + " " + term.rows);
}
});
window._termHandlersAttached = true;
}
};

ws.onmessage = (evt) => {
if (typeof evt.data === 'string') {
term.write(evt.data);
} else {
term.write(new Uint8Array(evt.data));
}
};

ws.onclose = () => {
setTimeout(connect, 200);
};
}
connect();
</script>
</body>
</html>
"""

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
2 changes: 2 additions & 0 deletions demos/TachikomaDemos/src/TachikomaDemos.jl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ using SQLite
using DBInterface
@tachikoma_app

include("run_demo.jl")
include("theme_demo.jl")
include("rain.jl")
include("dashboard.jl")
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/anim_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/ansi_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/async_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/backend_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/chaos.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/chart_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/clado_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/clock.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/colortypes_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/dashboard.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/datatable_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/dotwave.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/editor_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/effects_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/form_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion demos/TachikomaDemos/src/fps_demo.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 6 additions & 3 deletions demos/TachikomaDemos/src/launcher.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand Down
Loading
Loading