Compare commits
3 Commits
8ecb7d4382
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
9f6e607e78
|
|||
|
3d68f08e1d
|
|||
|
cf3dba8fe4
|
BIN
.github/assets/CommandNotch-Demo.mp4
vendored
Normal file
BIN
.github/assets/CommandNotch-Open.png
vendored
Normal file
|
After Width: | Height: | Size: 846 KiB |
BIN
.github/assets/CommandNotch-Settings.png
vendored
Normal file
|
After Width: | Height: | Size: 575 KiB |
BIN
.github/assets/bch-receiving-address.png
vendored
Normal file
|
After Width: | Height: | Size: 58 KiB |
422
.github/demo/aurora.py
vendored
Executable 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:]))
|
||||
4
.gitignore
vendored
@@ -79,7 +79,5 @@ jspm_packages/
|
||||
dist/
|
||||
build/
|
||||
|
||||
**/Release*
|
||||
CommandNotch 20*
|
||||
|
||||
# Mac... files
|
||||
**/.DS_Store
|
||||
89
CONTRIBUTING.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for contributing to CommandNotch.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Use macOS 14+.
|
||||
- Install Xcode 16+.
|
||||
- Install `xcodegen` with Homebrew.
|
||||
|
||||
## Local Setup
|
||||
|
||||
```bash
|
||||
cd CommandNotch
|
||||
xcodegen generate --spec project.yml
|
||||
open CommandNotch.xcodeproj
|
||||
```
|
||||
|
||||
## Useful Commands
|
||||
|
||||
Generate the project:
|
||||
|
||||
```bash
|
||||
cd CommandNotch
|
||||
xcodegen generate --spec project.yml
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cd CommandNotch
|
||||
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
|
||||
xcodebuild build -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd CommandNotch
|
||||
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
|
||||
xcodebuild test -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
|
||||
```
|
||||
|
||||
## What Helps Most
|
||||
|
||||
- Bug fixes with a clear reproduction path.
|
||||
- UI polish that keeps the app feeling intentional instead of generic.
|
||||
- Accessibility improvements.
|
||||
- Tests around workspace, screen, settings, and hotkey behavior.
|
||||
- Docs and onboarding improvements.
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
- Keep changes targeted.
|
||||
- Preserve the existing SwiftUI + AppKit split.
|
||||
- Prefer typed settings and explicit state ownership over hidden side effects.
|
||||
- Add or update tests when you change behavior.
|
||||
- Regenerate the Xcode project with XcodeGen if you add or remove files.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Keep PRs small enough to review comfortably.
|
||||
- Explain the user-facing impact.
|
||||
- Note any follow-up work or tradeoffs.
|
||||
- Include screenshots for visible UI changes when possible.
|
||||
- Mention the exact build/test command you used.
|
||||
|
||||
## Issues
|
||||
|
||||
When filing a bug, include:
|
||||
|
||||
- macOS version
|
||||
- what you expected
|
||||
- what actually happened
|
||||
- reproduction steps
|
||||
- screenshots or recordings if they help
|
||||
|
||||
## Media Updates
|
||||
|
||||
README screenshots live in `.github/assets/`.
|
||||
|
||||
If you update the UI significantly, refresh:
|
||||
|
||||
- `.github/assets/CommandNotch-Open.png`
|
||||
- `.github/assets/CommandNotch-Settings.png`
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be released under the MIT License in this repository.
|
||||
@@ -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()
|
||||
|
||||
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) {}
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 945 B After Width: | Height: | Size: 945 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 735 B After Width: | Height: | Size: 735 B |
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 290 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 958 B |
|
Before Width: | Height: | Size: 512 B After Width: | Height: | Size: 512 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Harvey Zuccon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
111
README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# CommandNotch
|
||||
|
||||
<p align="center">
|
||||
<img src="icons/Downterm-icon-256.png" width="128" alt="CommandNotch icon">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A drop-down terminal for macOS that lives in the notch area.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/platform-macOS%2014%2B-black" alt="macOS 14+">
|
||||
<img src="https://img.shields.io/badge/UI-SwiftUI%20%2B%20AppKit-black" alt="SwiftUI and AppKit">
|
||||
<img src="https://img.shields.io/badge/license-MIT-black" alt="MIT License">
|
||||
</p>
|
||||
|
||||
CommandNotch is a notch-native terminal overlay for macOS. It gives you a fast shell without switching spaces, keeping a full Terminal window open, or breaking your flow. Open it with a hotkey, drop into a shell, then get out of the way just as quickly.
|
||||
|
||||
The current Xcode target and bundle name are still `CommandNotch`, but the project is being presented publicly as **CommandNotch**.
|
||||
|
||||
## Why CommandNotch
|
||||
|
||||
- You want a terminal that is always one shortcut away.
|
||||
- You like the idea of a terminal living in the menu bar / notch area instead of a full window.
|
||||
- You work across multiple displays and want each screen to keep its own notch state.
|
||||
- You want lightweight workspaces, detachable tabs, and shell access without giving up native macOS feel.
|
||||
|
||||
## Features
|
||||
|
||||
- Native macOS notch overlay with open and closed states.
|
||||
- Fast shell sessions powered by SwiftTerm and a local login shell.
|
||||
- Multiple tabs with hotkeys for new, close, next, previous, and direct tab switching.
|
||||
- Workspace support, including shared workspaces across screens.
|
||||
- Multi-display awareness with per-screen assignment and presentation state.
|
||||
- Detachable tabs that can pop out into standalone terminal windows.
|
||||
- Terminal themes: Classic, Xterm, Solarized Dark, Dracula, and Nord.
|
||||
- Configurable terminal size presets with optional hotkeys.
|
||||
- Hover-to-open behavior and animation tuning.
|
||||
- Launch at login support.
|
||||
- Global toggle hotkey and notch-scoped shortcut handling.
|
||||
- Terminal-friendly macOS key behavior for `Command+Arrow`, `Option+Arrow`, `Command+Backspace`, and `Command+L`.
|
||||
|
||||
## Gallery
|
||||
|
||||
### Demo
|
||||
|
||||
[](.github/assets/CommandNotch-Demo.mp4)
|
||||
|
||||
Click the preview above to watch the demo recording.
|
||||
|
||||
### Open Notch Terminal
|
||||
|
||||

|
||||
|
||||
### Settings
|
||||
|
||||

|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
|
||||
- macOS 14 or later
|
||||
- Xcode 16 or later
|
||||
- Homebrew `xcodegen`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd CommandNotch
|
||||
xcodegen generate --spec project.yml
|
||||
open CommandNotch.xcodeproj
|
||||
```
|
||||
|
||||
Or from the command line:
|
||||
|
||||
```bash
|
||||
cd CommandNotch
|
||||
xcodegen generate --spec project.yml
|
||||
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
|
||||
xcodebuild build -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
|
||||
```
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
CommandNotch/
|
||||
├── CommandNotch/ # XcodeGen spec, app target, tests
|
||||
├── docs/ # architecture and planning notes
|
||||
├── icons/ # app icons and branding assets
|
||||
└── .github/assets/ # README screenshots and support assets
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. If you want to fix bugs, improve the UX, tighten the architecture, or help polish the public release, start with [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Help Fund Development
|
||||
|
||||
If CommandNotch saves you time, support helps justify more polish and faster iteration.
|
||||
|
||||
- Ko-fi: https://ko-fi.com/harvmaster
|
||||
- BCH: `bitcoincash:zq5xlhahsk8svzk562m3kwrzgd9hrm80mcu8slnzv3`
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/bch-receiving-address.png" width="220" alt="BCH receiving address QR code">
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
CommandNotch is released under the [MIT License](LICENSE).
|
||||
@@ -618,7 +618,7 @@ Exit criteria:
|
||||
## Proposed File Layout
|
||||
|
||||
```text
|
||||
Downterm/CommandNotch/
|
||||
CommandNotch/CommandNotch/
|
||||
App/
|
||||
CommandNotchApp.swift
|
||||
AppDelegate.swift
|
||||
|
||||
@@ -321,12 +321,12 @@ The first deliverable should not include:
|
||||
|
||||
## Files Likely Impacted
|
||||
|
||||
- `Downterm/CommandNotch/Models/WorkspaceController.swift`
|
||||
- `Downterm/CommandNotch/Models/WorkspaceRegistry.swift`
|
||||
- `Downterm/CommandNotch/Models/WorkspaceStore.swift`
|
||||
- `Downterm/CommandNotch/ContentView.swift`
|
||||
- `Downterm/CommandNotch/Components/TabBar.swift`
|
||||
- `Downterm/CommandNotch/Models/TerminalSession.swift`
|
||||
- `CommandNotch/CommandNotch/Models/WorkspaceController.swift`
|
||||
- `CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift`
|
||||
- `CommandNotch/CommandNotch/Models/WorkspaceStore.swift`
|
||||
- `CommandNotch/CommandNotch/ContentView.swift`
|
||||
- `CommandNotch/CommandNotch/Components/TabBar.swift`
|
||||
- `CommandNotch/CommandNotch/Models/TerminalSession.swift`
|
||||
- workspace-related tests
|
||||
|
||||
Likely new files:
|
||||
|
||||