Add mascot demo. Update assets for readme

This commit is contained in:
2026-03-14 02:45:15 +11:00
parent cf3dba8fe4
commit 3d68f08e1d
13 changed files with 568 additions and 21 deletions

BIN
.github/assets/CommandNotch-Demo.mp4 vendored Normal file

Binary file not shown.

BIN
.github/assets/CommandNotch-Open.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

BIN
.github/assets/CommandNotch-Settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

View File

@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640" role="img" aria-labelledby="title desc">
<title id="title">BCH QR placeholder</title>
<desc id="desc">Placeholder card reminding the maintainer to replace this with a real BCH QR code.</desc>
<rect width="640" height="640" rx="32" fill="#111111"/>
<rect x="64" y="64" width="512" height="512" rx="24" fill="#ffffff"/>
<path d="M156 156h128v128H156zM356 156h128v128H356zM156 356h128v128H156z" fill="#111111"/>
<path d="M192 192h56v56h-56zM392 192h56v56h-56zM192 392h56v56h-56z" fill="#ffffff"/>
<path d="M332 340h32v32h-32zM396 340h32v32h-32zM460 340h32v32h-32zM332 404h32v32h-32zM460 404h32v32h-32zM364 436h32v32h-32zM428 436h32v32h-32z" fill="#111111"/>
<text x="320" y="560" text-anchor="middle" fill="#111111" font-family="Menlo, Monaco, monospace" font-size="24">REPLACE WITH REAL BCH QR</text>
<text x="320" y="602" text-anchor="middle" fill="#ffffff" font-family="Menlo, Monaco, monospace" font-size="24">ADD YOUR bitcoincash: ADDRESS</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

BIN
.github/assets/bch-receiving-address.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

422
.github/demo/aurora.py vendored Executable file
View File

@@ -0,0 +1,422 @@
#!/usr/bin/env python3
"""Looping bathtub-and-duck terminal animation for Downterm demos."""
from __future__ import annotations
import argparse
import atexit
import math
import shutil
import signal
import sys
import time
from typing import Sequence
CSI = "\x1b["
RESET = f"{CSI}0m"
HIDE_CURSOR = f"{CSI}?25l"
SHOW_CURSOR = f"{CSI}?25h"
ALT_SCREEN_ON = f"{CSI}?1049h"
ALT_SCREEN_OFF = f"{CSI}?1049l"
CLEAR = f"{CSI}2J"
HOME = f"{CSI}H"
TUB = "tub"
TUB_DARK = "tub_dark"
WATER = "water"
WATER_DEEP = "water_deep"
FOAM = "foam"
BUBBLE = "bubble"
DUCK = "duck"
DUCK_LIGHT = "duck_light"
BEAK = "beak"
EYE = "eye"
LABEL = "label"
LABEL_SHADOW = "label_shadow"
RGB = {
TUB: (171, 177, 191),
TUB_DARK: (112, 118, 133),
WATER: (71, 185, 214),
WATER_DEEP: (36, 121, 161),
FOAM: (196, 242, 250),
BUBBLE: (184, 233, 245),
DUCK: (255, 208, 64),
DUCK_LIGHT: (255, 232, 132),
BEAK: (255, 154, 58),
EYE: (251, 251, 253),
LABEL: (207, 212, 222),
LABEL_SHADOW: (74, 79, 92),
}
WATER_SHADE = " .:-=+*#"
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render a looping bathtub-and-duck animation."
)
parser.add_argument(
"--fps",
type=float,
default=18.0,
help="Target frames per second. Default: 18",
)
parser.add_argument(
"--speed",
type=float,
default=1.0,
help="Animation speed multiplier. Default: 1.0",
)
parser.add_argument(
"--duration",
type=float,
default=0.0,
help="Optional run duration in seconds. Default: infinite",
)
parser.add_argument(
"--label",
default="",
help="Optional centered label, e.g. --label Downterm",
)
parser.add_argument(
"--mono",
action="store_true",
help="Disable color and render as monochrome ASCII.",
)
return parser.parse_args(argv)
def terminal_size() -> tuple[int, int]:
size = shutil.get_terminal_size((90, 28))
return max(40, size.columns), max(12, size.lines)
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def scene_size(width: int, height: int) -> tuple[int, int]:
visible_height = max(8, height - 1)
scene_width = min(max(48, width - 2), width, 110)
scene_height = min(max(16, visible_height - 1), visible_height, 24)
return scene_width, scene_height
def make_canvas(width: int, height: int) -> list[list[tuple[str, str | None]]]:
return [[(" ", None) for _ in range(width)] for _ in range(height)]
def put(canvas: list[list[tuple[str, str | None]]], x: int, y: int, char: str, color: str | None) -> None:
if 0 <= y < len(canvas) and 0 <= x < len(canvas[y]) and char:
canvas[y][x] = (char[0], color)
def draw_text(
canvas: list[list[tuple[str, str | None]]],
x: int,
y: int,
text: str,
color: str | None,
) -> None:
for index, char in enumerate(text):
if char != " ":
put(canvas, x + index, y, char, color)
def draw_sprite_text(
canvas: list[list[tuple[str, str | None]]],
x: int,
y: int,
text: str,
color: str | None,
) -> None:
for index, char in enumerate(text):
if char == " ":
continue
if char == "~":
put(canvas, x + index, y, " ", None)
continue
put(canvas, x + index, y, char, color)
def water_char(intensity: float) -> str:
index = int(clamp(intensity, 0.0, 0.9999) * len(WATER_SHADE))
return WATER_SHADE[index]
def tub_geometry(scene_width: int, scene_height: int) -> dict[str, int]:
top_y = 1
notch_width = max(28, min(scene_width - 18, int(scene_width * 0.44)))
rim_left = max(8, (scene_width - notch_width) // 2)
rim_right = min(scene_width - 9, rim_left + notch_width)
wall_left = rim_left + 1
wall_right = rim_right - 1
body_bottom = min(scene_height - 10, top_y + max(6, scene_height // 3))
water_top = top_y + 2
return {
"rim_left": rim_left,
"rim_right": rim_right,
"top_y": top_y,
"wall_left": wall_left,
"wall_right": wall_right,
"body_bottom": body_bottom,
"water_top": water_top,
}
def draw_tub(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int]) -> None:
rim_left = geometry["rim_left"]
rim_right = geometry["rim_right"]
top_y = geometry["top_y"]
wall_left = geometry["wall_left"]
wall_right = geometry["wall_right"]
body_bottom = geometry["body_bottom"]
draw_text(canvas, 2, top_y, "_" * max(0, rim_left - 2), TUB)
put(canvas, rim_left, top_y, "_", TUB)
draw_text(canvas, rim_right + 1, top_y, "_" * max(0, len(canvas[0]) - rim_right - 3), TUB)
put(canvas, rim_right, top_y, "_", TUB)
for y in range(top_y + 1, body_bottom):
put(canvas, wall_left, y, "|", TUB)
put(canvas, wall_right, y, "|", TUB)
put(canvas, wall_left, body_bottom, "\\", TUB_DARK)
draw_text(canvas, wall_left + 1, body_bottom, "_" * max(0, wall_right - wall_left - 1), TUB_DARK)
put(canvas, wall_right, body_bottom, "/", TUB_DARK)
def draw_water_fill(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
rim_left = geometry["wall_left"] + 1
rim_right = geometry["wall_right"] - 1
water_top = geometry["water_top"]
body_bottom = geometry["body_bottom"] - 1
depth = max(1, body_bottom - water_top)
for row in range(depth + 1):
y = water_top + row
for x in range(rim_left, rim_right + 1):
wave = math.sin((x * 0.23) + (t * 2.1) + row * 0.55)
shimmer = math.sin((x * 0.07) - (t * 1.35))
if row == 0:
char = "~" if wave > -0.22 else "-"
color = FOAM if shimmer > 0.25 else WATER
else:
density = 0.40 + row / max(1, depth)
value = 0.45 + (wave * 0.25) + (shimmer * 0.10) + density * 0.30
char = water_char(value)
color = WATER if row <= 2 else WATER_DEEP
put(canvas, x, y, char, color)
def draw_water_surface(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
left = geometry["wall_left"] + 1
right = geometry["wall_right"] - 1
y = geometry["water_top"]
for x in range(left, right + 1):
wave = math.sin((x * 0.23) + (t * 2.1))
shimmer = math.sin((x * 0.11) - (t * 1.15))
char = "~" if wave > -0.18 else "-"
color = FOAM if shimmer > 0.35 else WATER
put(canvas, x, y, char, color)
def draw_bubbles(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
rim_left = geometry["wall_left"]
water_top = geometry["water_top"]
seeds = (
(0.00, rim_left + 11, 0.9),
(0.29, rim_left + 19, 1.1),
(0.57, rim_left + 27, 0.8),
(0.82, rim_left + 35, 1.0),
)
for offset, base_x, speed in seeds:
progress = (t * 0.11 * speed + offset) % 1.0
y = water_top + 4 - int(progress * 9)
x = base_x + int(round(math.sin((t * 0.8 * speed) + offset * 8.0) * 2))
put(canvas, x, y, "o" if progress < 0.5 else ".", BUBBLE)
def draw_duck(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None:
rim_left = geometry["wall_left"]
rim_right = geometry["wall_right"]
water_top = geometry["water_top"]
span = rim_right - rim_left
bob = math.sin(t * 1.35) * 0.55
drift = math.sin(t * 0.55) * 2.0
blink_phase = int(t * 2.1) % 12
eye_char = "-" if blink_phase == 7 else "."
x = int(rim_left + span * 0.34 + drift)
y = int(water_top - 1 + bob)
rows = [
" __",
f" ___(~{eye_char})>",
" \\~<_.~) ",
" `---' ",
]
colors = [DUCK_LIGHT, DUCK, DUCK, DUCK]
for index, row in enumerate(rows):
draw_sprite_text(canvas, x, y + index, row, colors[index])
put(canvas, x + 8, y + 1, ">", BEAK)
put(canvas, x + 6, y + 1, eye_char, EYE)
if 0.74 < (t * 0.18) % 1.0 < 0.90:
splash_x = x + 9
splash_y = geometry["water_top"] - 1
for dx, dy, char in ((0, 0, "."), (2, -1, "o"), (4, 0, "."), (5, -2, "o")):
put(canvas, splash_x + dx, splash_y + dy, char, BUBBLE)
def logo_rows(text: str) -> list[str]:
if text == "commandNotch":
return [
" ██████╗ ██████╗ ███╗ ███╗███╗ ███╗ █████╗ ███╗ ██╗██████╗ ",
" ██╔════╝██╔═══██╗████╗ ████║████╗ ████║██╔══██╗████╗ ██║██╔══██╗",
" ██║ ██║ ██║██╔████╔██║██╔████╔██║███████║██╔██╗ ██║██║ ██║",
" ██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║██╔══██║██║╚██╗██║██║ ██║",
" ╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║██║ ██║██║ ╚████║██████╔╝",
" ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ",
"",
" ███╗ ██╗ ██████╗ ████████╗ ██████╗██╗ ██╗",
" ████╗ ██║██╔═══██╗╚══██╔══╝██╔════╝██║ ██║",
" ██╔██╗ ██║██║ ██║ ██║ ██║ ███████║",
" ██║╚██╗██║██║ ██║ ██║ ██║ ██╔══██║",
" ██║ ╚████║╚██████╔╝ ██║ ╚██████╗██║ ██║",
" ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═════╝╚═╝ ╚═╝",
]
return [text]
def draw_label(canvas: list[list[tuple[str, str | None]]], label: str) -> None:
logo = logo_rows(label or "commandNotch")
start_y = len(canvas) - len(logo) - 1
for index, row in enumerate(logo):
start_x = max(0, (len(canvas[0]) - len(row)) // 2)
draw_text(canvas, start_x, start_y + index, row, LABEL)
def scene_canvas(scene_width: int, scene_height: int, t: float, label: str) -> list[list[tuple[str, str | None]]]:
canvas = make_canvas(scene_width, scene_height)
geometry = tub_geometry(scene_width, scene_height)
draw_tub(canvas, geometry)
draw_water_fill(canvas, geometry, t)
draw_water_surface(canvas, geometry, t)
draw_duck(canvas, geometry, t)
draw_bubbles(canvas, geometry, t)
draw_label(canvas, label)
return canvas
def ansi_rgb(color_name: str | None) -> str:
if color_name is None:
return RESET
red, green, blue = RGB[color_name]
return f"{CSI}38;2;{red};{green};{blue}m"
def render_scene_line(cells: list[tuple[str, str | None]], mono: bool) -> str:
if mono:
return "".join(char for char, _ in cells)
chunks: list[str] = []
current_color: str | None = None
for char, color in cells:
if color != current_color:
chunks.append(ansi_rgb(color))
current_color = color
chunks.append(char)
chunks.append(RESET)
return "".join(chunks)
def render_frame(width: int, height: int, t: float, label: str, mono: bool) -> str:
visible_height = max(8, height - 1)
scene_width, scene_height = scene_size(width, height)
scene = scene_canvas(scene_width, scene_height, t, label)
top_padding = max(0, (visible_height - scene_height) // 2)
left_padding = max(0, (width - scene_width) // 2)
pieces: list[str] = [HOME]
blank_line = " " * width
for row in range(visible_height):
if top_padding <= row < top_padding + scene_height:
scene_row = scene[row - top_padding]
rendered = render_scene_line(scene_row, mono)
right_padding = max(0, width - left_padding - scene_width)
line = (" " * left_padding) + rendered + (" " * right_padding)
else:
line = blank_line
pieces.append(line)
if row != visible_height - 1:
pieces.append("\n")
return "".join(pieces)
def restore_terminal() -> None:
sys.stdout.write(f"{RESET}{SHOW_CURSOR}{ALT_SCREEN_OFF}")
sys.stdout.flush()
def install_cleanup() -> None:
def handle_signal(signum, _frame) -> None:
restore_terminal()
raise SystemExit(128 + signum)
atexit.register(restore_terminal)
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
def main(argv: Sequence[str]) -> int:
args = parse_args(argv)
if args.fps <= 0:
raise SystemExit("--fps must be greater than 0")
if args.speed <= 0:
raise SystemExit("--speed must be greater than 0")
install_cleanup()
sys.stdout.write(f"{ALT_SCREEN_ON}{HIDE_CURSOR}{CLEAR}")
sys.stdout.flush()
frame_interval = 1.0 / args.fps
start = time.perf_counter()
frame_index = 0
while True:
now = time.perf_counter()
elapsed = now - start
if args.duration > 0 and elapsed >= args.duration:
break
width, height = terminal_size()
t = elapsed * args.speed
sys.stdout.write(render_frame(width, height, t, args.label, args.mono))
sys.stdout.flush()
frame_index += 1
target = start + (frame_index * frame_interval)
sleep_for = target - time.perf_counter()
if sleep_for > 0:
time.sleep(sleep_for)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -12,14 +12,23 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
private var keyEventMonitor: Any?
private let backgroundColor = NSColor.black
private let configuredShellPath: String
private let launchDirectory: String
@Published var title: String = "shell"
@Published var isRunning: Bool = true
@Published var currentDirectory: String?
init(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) {
init(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String? = nil,
startImmediately: Bool = true
) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
configuredShellPath = shellPath
launchDirectory = Self.resolveInitialDirectory(initialDirectory)
currentDirectory = launchDirectory
super.init()
terminalView.terminalDelegate = self
@@ -29,7 +38,11 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
applyTheme(theme)
installCommandArrowMonitor()
startShell()
if startImmediately {
startShell()
} else {
isRunning = false
}
}
deinit {
@@ -52,12 +65,29 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
args: ["-l"],
environment: nil,
execName: loginExecName,
currentDirectory: NSHomeDirectory()
currentDirectory: launchDirectory
)
process = proc
title = shellName
}
private static func resolveInitialDirectory(_ directory: String?) -> String {
normalizedDirectory(directory) ?? NSHomeDirectory()
}
private static func normalizedDirectory(_ directory: String?) -> String? {
let trimmed = directory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else {
return nil
}
if let url = URL(string: trimmed), url.isFileURL {
return url.path(percentEncoded: false)
}
return (trimmed as NSString).expandingTildeInPath
}
private func resolveShell() -> String {
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
@@ -145,7 +175,8 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
currentDirectory = directory
guard let normalizedDirectory = Self.normalizedDirectory(directory) else { return }
currentDirectory = normalizedDirectory
}
func scrolled(source: TerminalView, position: Double) {}

View File

@@ -3,12 +3,27 @@ import Combine
@MainActor
protocol TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession
}
struct LiveTerminalSessionFactory: TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
TerminalSession(fontSize: fontSize, theme: theme, shellPath: shellPath)
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
TerminalSession(
fontSize: fontSize,
theme: theme,
shellPath: shellPath,
initialDirectory: initialDirectory
)
}
}
@@ -90,7 +105,8 @@ final class WorkspaceController: ObservableObject {
let session = sessionFactory.makeSession(
fontSize: config.fontSize,
theme: config.theme,
shellPath: config.shellPath
shellPath: config.shellPath,
initialDirectory: activeTab?.currentDirectory
)
titleObservers[session.id] = session.$title

View File

@@ -313,7 +313,12 @@ private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigura
private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
fatalError("ScreenRegistryTests should not create live terminal sessions.")
}
}

View File

@@ -120,6 +120,64 @@ final class WorkspaceRegistryTests: XCTestCase {
}
}
@MainActor
final class WorkspaceControllerTests: XCTestCase {
func testNewTabUsesActiveTabCurrentDirectory() {
let factory = RecordingTerminalSessionFactory()
let controller = WorkspaceController(
summary: WorkspaceSummary(name: "Main"),
sessionFactory: factory,
settingsProvider: TestSettingsProvider(),
bootstrapDefaultTab: false
)
controller.newTab()
controller.activeTab?.currentDirectory = "/tmp/Raycast"
controller.newTab()
XCTAssertEqual(factory.requestedDirectories, [nil, "/tmp/Raycast"])
XCTAssertEqual(controller.activeTab?.currentDirectory, "/tmp/Raycast")
XCTAssertEqual(controller.tabs.count, 2)
XCTAssertEqual(controller.activeTabIndex, 1)
}
func testNewTabNormalizesCurrentDirectoryFileURL() {
let factory = RecordingTerminalSessionFactory()
let controller = WorkspaceController(
summary: WorkspaceSummary(name: "Main"),
sessionFactory: factory,
settingsProvider: TestSettingsProvider(),
bootstrapDefaultTab: false
)
let expectedPath = "/tmp/Raycast Folder"
controller.newTab()
controller.activeTab?.currentDirectory = URL(fileURLWithPath: expectedPath).absoluteString
controller.newTab()
XCTAssertEqual(controller.activeTab?.currentDirectory, expectedPath)
}
func testNewTabFallsBackToHomeDirectoryWhenWorkspaceHasNoTabs() {
let factory = RecordingTerminalSessionFactory()
let controller = WorkspaceController(
summary: WorkspaceSummary(name: "Main"),
sessionFactory: factory,
settingsProvider: TestSettingsProvider(),
bootstrapDefaultTab: false
)
controller.newTab()
XCTAssertEqual(factory.requestedDirectories, [nil])
XCTAssertEqual(controller.activeTab?.currentDirectory, NSHomeDirectory())
XCTAssertEqual(controller.tabs.count, 1)
XCTAssertEqual(controller.activeTabIndex, 0)
}
}
private final class InMemoryWorkspaceStore: WorkspaceStoreType {
var savedSummaries: [WorkspaceSummary] = []
@@ -138,9 +196,35 @@ private final class TestSettingsProvider: TerminalSessionConfigurationProviding
let terminalSizePresets = TerminalSizePresetStore.loadDefaults()
}
private final class RecordingTerminalSessionFactory: TerminalSessionFactoryType {
private(set) var requestedDirectories: [String?] = []
@MainActor
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
requestedDirectories.append(initialDirectory)
return TerminalSession(
fontSize: fontSize,
theme: theme,
shellPath: shellPath,
initialDirectory: initialDirectory,
startImmediately: false
)
}
}
private struct UnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
func makeSession(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
initialDirectory: String?
) -> TerminalSession {
fatalError("WorkspaceRegistryTests should not create live terminal sessions.")
}
}