Compare commits
15 Commits
1777eac0d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
9f6e607e78
|
|||
|
3d68f08e1d
|
|||
|
cf3dba8fe4
|
|||
|
8ecb7d4382
|
|||
|
1e30e9bf9e
|
|||
|
fe6c7d8c12
|
|||
|
e4719cb9f4
|
|||
|
256998eb9f
|
|||
|
9d05bc586a
|
|||
|
a6c8218bab
|
|||
|
ce20a46ccc
|
|||
|
23dc8d0be3
|
|||
|
81a296609a
|
|||
|
5d161bb214
|
|||
|
2bf1cbad2a
|
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:]))
|
||||||
3
.gitignore
vendored
@@ -78,3 +78,6 @@ jspm_packages/
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# 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.
|
||||||
787
CommandNotch/CommandNotch.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
088FE22B3308AAB25027E086 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F57837A7115DEEE11E14B40 /* NotchShape.swift */; };
|
||||||
|
0DE8E7F29FBAA7E2B613E221 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */; };
|
||||||
|
0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */; };
|
||||||
|
12F68EDA880030DAB644FF5F /* LaunchAtLoginHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */; };
|
||||||
|
154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */; };
|
||||||
|
187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */; };
|
||||||
|
1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */; };
|
||||||
|
2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */; };
|
||||||
|
2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */; };
|
||||||
|
23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */; };
|
||||||
|
26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */; };
|
||||||
|
278010607B0D552DCC8996C5 /* CommandNotchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */; };
|
||||||
|
2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */; };
|
||||||
|
34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */; };
|
||||||
|
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */; };
|
||||||
|
4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */; };
|
||||||
|
4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6C98C406899F4B242075AF /* AppSettingsStore.swift */; };
|
||||||
|
4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */; };
|
||||||
|
507A67E770DEFAF5BC321FCF /* WindowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */; };
|
||||||
|
5379DF2FACE924BDDB584377 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = C921E6435A64AA07A0FEA4D5 /* SwiftTerm */; };
|
||||||
|
5A3FDFCF30A1AAFE070290E9 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */; };
|
||||||
|
5F3534F66A5DBE4E081AFFA6 /* SwiftTermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */; };
|
||||||
|
65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */; };
|
||||||
|
6624A60CB121A22A03365071 /* WorkspaceSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */; };
|
||||||
|
6A18F6635B509FF58669F505 /* HotkeySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */; };
|
||||||
|
6DD4DAE72E5C3858B230D94C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A8DCFA77626F9999B432FE /* AppDelegate.swift */; };
|
||||||
|
6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567E85A2ED628460CEC760DB /* TerminalManager.swift */; };
|
||||||
|
771088361F981A9AAE976F3C /* SettingsBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 900F0476BE9E3600FBD371BB /* SettingsBindings.swift */; };
|
||||||
|
787477BB6D2F8AC723EEDA7D /* ScreenContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7181BB1F3926B457445105E5 /* ScreenContext.swift */; };
|
||||||
|
7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E1791BB45E1505500ACC67 /* TerminalSession.swift */; };
|
||||||
|
7A7D3455D50D041CF698E786 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74463E4EAB78F56345360CD5 /* AppSettings.swift */; };
|
||||||
|
7AB14019B5CC6ED84B96FA47 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */; };
|
||||||
|
7D5F3C3B5E173B6F06901812 /* AppSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */; };
|
||||||
|
86BB4A41E8EC44A5F45CE995 /* WorkspaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */; };
|
||||||
|
88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A1D3D12BAC593838B3125C /* TabBar.swift */; };
|
||||||
|
88D87155D79C493D8956AA3B /* AnimationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */; };
|
||||||
|
8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */; };
|
||||||
|
9F535B9516A4AE9FB536B1BD /* HotkeyBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */; };
|
||||||
|
A74E14B8AD19C53820853D8E /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */; };
|
||||||
|
AE8215538D4B026A00BAD241 /* WorkspaceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */; };
|
||||||
|
B406183CC7B5DCE76989A066 /* AppSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB28950392C0198E69F3564B /* AppSettingsController.swift */; };
|
||||||
|
B605A9311557251A85183383 /* AboutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */; };
|
||||||
|
C84FA3100884649FE92BF5DD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64A11F27E65B342B991629A /* ContentView.swift */; };
|
||||||
|
CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */; };
|
||||||
|
D088BC850F37E717844761C6 /* CommandNotchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */; };
|
||||||
|
D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */; };
|
||||||
|
D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */; };
|
||||||
|
DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3125FD3DC55420122CF85D80 /* SettingsView.swift */; };
|
||||||
|
DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */; };
|
||||||
|
E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */; };
|
||||||
|
E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */; };
|
||||||
|
EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */; };
|
||||||
|
F8ED522FDC96B1F9AD8933F7 /* ScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */; };
|
||||||
|
F8F8DE2F26608259D72635B7 /* AppSettingsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
13E720E97D079D298D124BBE /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = E99C2EDB39F7B64C1540BCA8 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = D5585E5732CD067DF6EF0C69;
|
||||||
|
remoteInfo = CommandNotch;
|
||||||
|
};
|
||||||
|
9D6387038E18398CA764147F /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = E99C2EDB39F7B64C1540BCA8 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = D5585E5732CD067DF6EF0C69;
|
||||||
|
remoteInfo = CommandNotch;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistry.swift; sourceTree = "<group>"; };
|
||||||
|
27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
|
||||||
|
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
|
||||||
|
3125FD3DC55420122CF85D80 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStoreTests.swift; sourceTree = "<group>"; };
|
||||||
|
3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = "<group>"; };
|
||||||
|
3F57837A7115DEEE11E14B40 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
|
||||||
|
3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = "<group>"; };
|
||||||
|
48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
49E1791BB45E1505500ACC67 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
|
||||||
|
4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchState.swift; sourceTree = "<group>"; };
|
||||||
|
4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStoreTests.swift; sourceTree = "<group>"; };
|
||||||
|
5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchApp.swift; sourceTree = "<group>"; };
|
||||||
|
567E85A2ED628460CEC760DB /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
|
||||||
|
591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistryTests.swift; sourceTree = "<group>"; };
|
||||||
|
5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
7181BB1F3926B457445105E5 /* ScreenContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContext.swift; sourceTree = "<group>"; };
|
||||||
|
726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchUITests.swift; sourceTree = "<group>"; };
|
||||||
|
728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
72A1D3D12BAC593838B3125C /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
|
||||||
|
74463E4EAB78F56345360CD5 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||||
|
7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSummary.swift; sourceTree = "<group>"; };
|
||||||
|
8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = "<group>"; };
|
||||||
|
8796768C84519077354A95C7 /* CommandNotch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CommandNotch.entitlements; sourceTree = "<group>"; };
|
||||||
|
8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
|
||||||
|
8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
|
||||||
|
900F0476BE9E3600FBD371BB /* SettingsBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBindings.swift; sourceTree = "<group>"; };
|
||||||
|
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
|
||||||
|
9E6C98C406899F4B242075AF /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = "<group>"; };
|
||||||
|
A64A11F27E65B342B991629A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContextTests.swift; sourceTree = "<group>"; };
|
||||||
|
AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistry.swift; sourceTree = "<group>"; };
|
||||||
|
B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandNotch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandArrowBehaviorTests.swift; sourceTree = "<group>"; };
|
||||||
|
CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoutWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = "<group>"; };
|
||||||
|
D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = "<group>"; };
|
||||||
|
DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = "<group>"; };
|
||||||
|
DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = "<group>"; };
|
||||||
|
E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
E7A8DCFA77626F9999B432FE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
EB28950392C0198E69F3564B /* AppSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsController.swift; sourceTree = "<group>"; };
|
||||||
|
EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = "<group>"; };
|
||||||
|
EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistryTests.swift; sourceTree = "<group>"; };
|
||||||
|
EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = "<group>"; };
|
||||||
|
F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandArrowBehavior.swift; sourceTree = "<group>"; };
|
||||||
|
F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = "<group>"; };
|
||||||
|
F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacesSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestratorTests.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
F3D057FF4247F13A1BBAE547 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
5379DF2FACE924BDDB584377 /* SwiftTerm in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
00BC30DD7FA5C3C26404733B /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
74463E4EAB78F56345360CD5 /* AppSettings.swift */,
|
||||||
|
EB28950392C0198E69F3564B /* AppSettingsController.swift */,
|
||||||
|
9E6C98C406899F4B242075AF /* AppSettingsStore.swift */,
|
||||||
|
8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */,
|
||||||
|
DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */,
|
||||||
|
297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */,
|
||||||
|
4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */,
|
||||||
|
7181BB1F3926B457445105E5 /* ScreenContext.swift */,
|
||||||
|
AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */,
|
||||||
|
567E85A2ED628460CEC760DB /* TerminalManager.swift */,
|
||||||
|
49E1791BB45E1505500ACC67 /* TerminalSession.swift */,
|
||||||
|
CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */,
|
||||||
|
3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */,
|
||||||
|
0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */,
|
||||||
|
F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */,
|
||||||
|
7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
618799FE544A4373B457DCDA /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7043235A31A4023478DA1302 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
84740FA9CF6A18B35EC82623 /* CommandNotch */,
|
||||||
|
A2F9603AB9C86C4EA62FFA59 /* CommandNotchTests */,
|
||||||
|
E6C841E864A0CC68B9B05BAC /* CommandNotchUITests */,
|
||||||
|
B269158E04E8E603B61448F0 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
84740FA9CF6A18B35EC82623 /* CommandNotch */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E7A8DCFA77626F9999B432FE /* AppDelegate.swift */,
|
||||||
|
5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */,
|
||||||
|
A64A11F27E65B342B991629A /* ContentView.swift */,
|
||||||
|
D3C008AD1EFEF08E3417396F /* Components */,
|
||||||
|
618799FE544A4373B457DCDA /* Extensions */,
|
||||||
|
D87DBBE2E8779343A36F3490 /* Managers */,
|
||||||
|
00BC30DD7FA5C3C26404733B /* Models */,
|
||||||
|
C2F0251EB52960C9F437154D /* Resources */,
|
||||||
|
DEE792769214DF028395EA86 /* Views */,
|
||||||
|
);
|
||||||
|
path = CommandNotch;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A2F9603AB9C86C4EA62FFA59 /* CommandNotchTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */,
|
||||||
|
39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */,
|
||||||
|
FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */,
|
||||||
|
A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */,
|
||||||
|
EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */,
|
||||||
|
C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */,
|
||||||
|
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */,
|
||||||
|
591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */,
|
||||||
|
4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */,
|
||||||
|
);
|
||||||
|
path = CommandNotchTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
B269158E04E8E603B61448F0 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */,
|
||||||
|
35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */,
|
||||||
|
496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C2F0251EB52960C9F437154D /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */,
|
||||||
|
8796768C84519077354A95C7 /* CommandNotch.entitlements */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D3C008AD1EFEF08E3417396F /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */,
|
||||||
|
3F57837A7115DEEE11E14B40 /* NotchShape.swift */,
|
||||||
|
EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */,
|
||||||
|
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */,
|
||||||
|
72A1D3D12BAC593838B3125C /* TabBar.swift */,
|
||||||
|
F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D87DBBE2E8779343A36F3490 /* Managers */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */,
|
||||||
|
8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */,
|
||||||
|
CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */,
|
||||||
|
8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */,
|
||||||
|
728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */,
|
||||||
|
F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */,
|
||||||
|
);
|
||||||
|
path = Managers;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
DEE792769214DF028395EA86 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */,
|
||||||
|
FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */,
|
||||||
|
8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */,
|
||||||
|
27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */,
|
||||||
|
D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */,
|
||||||
|
900F0476BE9E3600FBD371BB /* SettingsBindings.swift */,
|
||||||
|
3125FD3DC55420122CF85D80 /* SettingsView.swift */,
|
||||||
|
E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */,
|
||||||
|
F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */,
|
||||||
|
DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E6C841E864A0CC68B9B05BAC /* CommandNotchUITests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */,
|
||||||
|
);
|
||||||
|
path = CommandNotchUITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
036FDAECD12C0A679DA1F5D6 /* CommandNotchTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 983C618ACECB88BD023F005E /* Build configuration list for PBXNativeTarget "CommandNotchTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
4108B7D3B592DEBB95C689C4 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
316871D68B87C00F5A8FEECC /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = CommandNotchTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = CommandNotchTests;
|
||||||
|
productReference = 35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
1C8D00CBB29219BD347E9CC4 /* CommandNotchUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = CEAE976FCC3F5BD54E57E585 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
1215938A5211847A086CC444 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
F9E4A521E345300B08E257EA /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = CommandNotchUITests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = CommandNotchUITests;
|
||||||
|
productReference = 496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
|
D5585E5732CD067DF6EF0C69 /* CommandNotch */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E599EC932C075AF0FD763A0E /* Build configuration list for PBXNativeTarget "CommandNotch" */;
|
||||||
|
buildPhases = (
|
||||||
|
D5B7874B63551D908A4B76C8 /* Sources */,
|
||||||
|
3BD8FCDCDA6E37ED22A35CA5 /* Resources */,
|
||||||
|
F3D057FF4247F13A1BBAE547 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = CommandNotch;
|
||||||
|
packageProductDependencies = (
|
||||||
|
C921E6435A64AA07A0FEA4D5 /* SwiftTerm */,
|
||||||
|
);
|
||||||
|
productName = CommandNotch;
|
||||||
|
productReference = B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
E99C2EDB39F7B64C1540BCA8 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1600;
|
||||||
|
TargetAttributes = {
|
||||||
|
1C8D00CBB29219BD347E9CC4 = {
|
||||||
|
DevelopmentTeam = G698BP272N;
|
||||||
|
TestTargetID = D5585E5732CD067DF6EF0C69;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = 7043235A31A4023478DA1302;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||||
|
);
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
D5585E5732CD067DF6EF0C69 /* CommandNotch */,
|
||||||
|
036FDAECD12C0A679DA1F5D6 /* CommandNotchTests */,
|
||||||
|
1C8D00CBB29219BD347E9CC4 /* CommandNotchUITests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
3BD8FCDCDA6E37ED22A35CA5 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
7AB14019B5CC6ED84B96FA47 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
1215938A5211847A086CC444 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
278010607B0D552DCC8996C5 /* CommandNotchUITests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
4108B7D3B592DEBB95C689C4 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
F8F8DE2F26608259D72635B7 /* AppSettingsControllerTests.swift in Sources */,
|
||||||
|
7D5F3C3B5E173B6F06901812 /* AppSettingsStoreTests.swift in Sources */,
|
||||||
|
2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */,
|
||||||
|
D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */,
|
||||||
|
8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */,
|
||||||
|
CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */,
|
||||||
|
0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */,
|
||||||
|
154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */,
|
||||||
|
2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
D5B7874B63551D908A4B76C8 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
B605A9311557251A85183383 /* AboutSettingsView.swift in Sources */,
|
||||||
|
88D87155D79C493D8956AA3B /* AnimationSettingsView.swift in Sources */,
|
||||||
|
6DD4DAE72E5C3858B230D94C /* AppDelegate.swift in Sources */,
|
||||||
|
7A7D3455D50D041CF698E786 /* AppSettings.swift in Sources */,
|
||||||
|
B406183CC7B5DCE76989A066 /* AppSettingsController.swift in Sources */,
|
||||||
|
4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */,
|
||||||
|
0DE8E7F29FBAA7E2B613E221 /* AppearanceSettingsView.swift in Sources */,
|
||||||
|
D088BC850F37E717844761C6 /* CommandNotchApp.swift in Sources */,
|
||||||
|
C84FA3100884649FE92BF5DD /* ContentView.swift in Sources */,
|
||||||
|
34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */,
|
||||||
|
9F535B9516A4AE9FB536B1BD /* HotkeyBinding.swift in Sources */,
|
||||||
|
187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */,
|
||||||
|
E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */,
|
||||||
|
6A18F6635B509FF58669F505 /* HotkeySettingsView.swift in Sources */,
|
||||||
|
12F68EDA880030DAB644FF5F /* LaunchAtLoginHelper.swift in Sources */,
|
||||||
|
A74E14B8AD19C53820853D8E /* NSScreen+Extensions.swift in Sources */,
|
||||||
|
EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */,
|
||||||
|
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */,
|
||||||
|
088FE22B3308AAB25027E086 /* NotchShape.swift in Sources */,
|
||||||
|
23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */,
|
||||||
|
4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */,
|
||||||
|
D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */,
|
||||||
|
787477BB6D2F8AC723EEDA7D /* ScreenContext.swift in Sources */,
|
||||||
|
F8ED522FDC96B1F9AD8933F7 /* ScreenManager.swift in Sources */,
|
||||||
|
E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */,
|
||||||
|
771088361F981A9AAE976F3C /* SettingsBindings.swift in Sources */,
|
||||||
|
DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */,
|
||||||
|
5A3FDFCF30A1AAFE070290E9 /* SettingsWindowController.swift in Sources */,
|
||||||
|
5F3534F66A5DBE4E081AFFA6 /* SwiftTermView.swift in Sources */,
|
||||||
|
88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */,
|
||||||
|
DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */,
|
||||||
|
6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */,
|
||||||
|
7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */,
|
||||||
|
65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */,
|
||||||
|
1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */,
|
||||||
|
507A67E770DEFAF5BC321FCF /* WindowCoordinator.swift in Sources */,
|
||||||
|
86BB4A41E8EC44A5F45CE995 /* WorkspaceController.swift in Sources */,
|
||||||
|
4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */,
|
||||||
|
AE8215538D4B026A00BAD241 /* WorkspaceStore.swift in Sources */,
|
||||||
|
2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */,
|
||||||
|
6624A60CB121A22A03365071 /* WorkspaceSwitcherView.swift in Sources */,
|
||||||
|
26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
316871D68B87C00F5A8FEECC /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = D5585E5732CD067DF6EF0C69 /* CommandNotch */;
|
||||||
|
targetProxy = 13E720E97D079D298D124BBE /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
F9E4A521E345300B08E257EA /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = D5585E5732CD067DF6EF0C69 /* CommandNotch */;
|
||||||
|
targetProxy = 9D6387038E18398CA764147F /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
15F1D354AC7D5666A8317E25 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
|
||||||
|
PRODUCT_NAME = CommandNotch;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
4C713B8FE8B6293021AB974F /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
|
||||||
|
PRODUCT_NAME = CommandNotchTests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
7D032F3A06E313F1F92D39EC /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.10;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
860E4EAD454534A04683E7DD /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
|
||||||
|
PRODUCT_NAME = CommandNotch;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
87EEF9DE40EE78121DC1E68B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.10;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
C9CA7CD89BF1C9A9BC98C4CF /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
DEVELOPMENT_TEAM = G698BP272N;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
|
||||||
|
PRODUCT_NAME = CommandNotchUITests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_TARGET_NAME = CommandNotch;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E52824EDC7F4752F43B6301A /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
|
||||||
|
PRODUCT_NAME = CommandNotchTests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
F697F3FF95C1EB110FC25A5C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
DEVELOPMENT_TEAM = G698BP272N;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
|
||||||
|
PRODUCT_NAME = CommandNotchUITests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_TARGET_NAME = CommandNotch;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
983C618ACECB88BD023F005E /* Build configuration list for PBXNativeTarget "CommandNotchTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E52824EDC7F4752F43B6301A /* Debug */,
|
||||||
|
4C713B8FE8B6293021AB974F /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7D032F3A06E313F1F92D39EC /* Debug */,
|
||||||
|
87EEF9DE40EE78121DC1E68B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
CEAE976FCC3F5BD54E57E585 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
C9CA7CD89BF1C9A9BC98C4CF /* Debug */,
|
||||||
|
F697F3FF95C1EB110FC25A5C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
E599EC932C075AF0FD763A0E /* Build configuration list for PBXNativeTarget "CommandNotch" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
15F1D354AC7D5666A8317E25 /* Debug */,
|
||||||
|
860E4EAD454534A04683E7DD /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 1.2.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
C921E6435A64AA07A0FEA4D5 /* SwiftTerm */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||||
|
productName = SwiftTerm;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = E99C2EDB39F7B64C1540BCA8 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
|
||||||
|
BuildableName = "CommandNotchTests.xctest"
|
||||||
|
BlueprintName = "CommandNotchTests"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
|
||||||
|
BuildableName = "CommandNotchUITests.xctest"
|
||||||
|
BlueprintName = "CommandNotchUITests"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
|
||||||
|
BuildableName = "CommandNotchTests.xctest"
|
||||||
|
BlueprintName = "CommandNotchTests"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
|
||||||
|
BuildableName = "CommandNotchUITests.xctest"
|
||||||
|
BlueprintName = "CommandNotchUITests"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
|
BuildableName = "CommandNotch.app"
|
||||||
|
BlueprintName = "CommandNotch"
|
||||||
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -4,12 +4,22 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Downterm.xcscheme_^#shared#^_</key>
|
<key>CommandNotch.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Release-Downterm.xcscheme_^#shared#^_</key>
|
<key>CommandNotchTests.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
</dict>
|
||||||
|
<key>CommandNotchUITests.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
</dict>
|
||||||
|
<key>Release-CommandNotch.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
117
CommandNotch/CommandNotch/AppDelegate.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Application delegate that bootstraps the notch overlay system.
|
||||||
|
@MainActor
|
||||||
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private enum UITestLaunchArgument {
|
||||||
|
static let regularActivation = "--uitest-regular-activation"
|
||||||
|
static let showSettings = "--uitest-show-settings"
|
||||||
|
static let openNotch = "--uitest-open-notch"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let settingsController = AppSettingsController.shared
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
NotchSettings.registerDefaults()
|
||||||
|
|
||||||
|
if isRunningUITests {
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
} else {
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the launch-at-login toggle with the actual system state
|
||||||
|
// in case the user toggled it from System Settings.
|
||||||
|
settingsController.update {
|
||||||
|
$0.display.launchAtLogin = LaunchAtLoginHelper.isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenManager.shared.start()
|
||||||
|
observeDisplayPreference()
|
||||||
|
observeSizePreferences()
|
||||||
|
observeFontSizeChanges()
|
||||||
|
observeTerminalThemeChanges()
|
||||||
|
applyUITestLaunchBehaviorIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
ScreenManager.shared.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preference observers
|
||||||
|
|
||||||
|
/// Only rebuild windows when the display-count preference changes.
|
||||||
|
private func observeDisplayPreference() {
|
||||||
|
settingsController.$settings
|
||||||
|
.map(\.display.showOnAllDisplays)
|
||||||
|
.removeDuplicates()
|
||||||
|
.dropFirst()
|
||||||
|
.sink { _ in
|
||||||
|
ScreenManager.shared.rebuildWindows()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reposition (not rebuild) when any sizing preference changes.
|
||||||
|
private func observeSizePreferences() {
|
||||||
|
settingsController.$settings
|
||||||
|
.map(\.display.layoutSignature)
|
||||||
|
.removeDuplicates()
|
||||||
|
.dropFirst()
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
|
||||||
|
.sink { _ in
|
||||||
|
ScreenManager.shared.repositionWindows()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live-update terminal font size across all sessions.
|
||||||
|
private func observeFontSizeChanges() {
|
||||||
|
settingsController.$settings
|
||||||
|
.map(\.terminal.fontSize)
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { newSize in
|
||||||
|
guard newSize > 0 else { return }
|
||||||
|
WorkspaceRegistry.shared.updateAllWorkspacesFontSizes(CGFloat(newSize))
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live-update terminal colors across all sessions.
|
||||||
|
private func observeTerminalThemeChanges() {
|
||||||
|
settingsController.$settings
|
||||||
|
.map(\.terminal.themeRawValue)
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { newTheme in
|
||||||
|
WorkspaceRegistry.shared.updateAllWorkspacesThemes(TerminalTheme.resolve(newTheme))
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var launchArguments: [String] {
|
||||||
|
ProcessInfo.processInfo.arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRunningUITests: Bool {
|
||||||
|
launchArguments.contains(UITestLaunchArgument.regularActivation)
|
||||||
|
|| launchArguments.contains(UITestLaunchArgument.showSettings)
|
||||||
|
|| launchArguments.contains(UITestLaunchArgument.openNotch)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyUITestLaunchBehaviorIfNeeded() {
|
||||||
|
guard isRunningUITests else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async { @MainActor in
|
||||||
|
if self.launchArguments.contains(UITestLaunchArgument.showSettings) {
|
||||||
|
SettingsWindowController.shared.showSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.launchArguments.contains(UITestLaunchArgument.openNotch),
|
||||||
|
let screenID = ScreenRegistry.shared.activeScreenID() {
|
||||||
|
ScreenManager.shared.openNotch(screenID: screenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Main entry point for the Downterm application.
|
/// Main entry point for the CommandNotch application.
|
||||||
/// Provides a MenuBarExtra for quick access to settings and app controls.
|
/// Provides a MenuBarExtra for quick access to settings and app controls.
|
||||||
/// The notch windows and terminal sessions are managed by AppDelegate + ScreenManager.
|
/// The notch windows and terminal sessions are managed by AppDelegate + ScreenManager.
|
||||||
@main
|
@main
|
||||||
struct DowntermApp: App {
|
struct CommandNotchApp: App {
|
||||||
|
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
@StateObject private var settingsController = AppSettingsController.shared
|
||||||
@AppStorage(NotchSettings.Keys.showMenuBarIcon)
|
|
||||||
private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
MenuBarExtra("Downterm", systemImage: "terminal", isInserted: $showMenuBarIcon) {
|
MenuBarExtra(
|
||||||
|
"CommandNotch",
|
||||||
|
systemImage: "terminal",
|
||||||
|
isInserted: Binding(
|
||||||
|
get: { settingsController.settings.display.showMenuBarIcon },
|
||||||
|
set: { newValue in
|
||||||
|
settingsController.update { $0.display.showMenuBarIcon = newValue }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
Button("Toggle Notch") {
|
Button("Toggle Notch") {
|
||||||
ScreenManager.shared.toggleNotchOnActiveScreen()
|
ScreenManager.shared.toggleNotchOnActiveScreen()
|
||||||
}
|
}
|
||||||
@@ -27,7 +34,7 @@ struct DowntermApp: App {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Quit Downterm") {
|
Button("Quit CommandNotch") {
|
||||||
NSApplication.shared.terminate(nil)
|
NSApplication.shared.terminate(nil)
|
||||||
}
|
}
|
||||||
.keyboardShortcut("Q", modifiers: .command)
|
.keyboardShortcut("Q", modifiers: .command)
|
||||||
192
CommandNotch/CommandNotch/Components/HotkeyRecorderView.swift
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// A clickable field that records a keyboard shortcut when focused.
|
||||||
|
/// Click it, press a key combination, and it saves the binding.
|
||||||
|
struct HotkeyRecorderView: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var binding: HotkeyBinding
|
||||||
|
|
||||||
|
@State private var isRecording = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
|
||||||
|
HotkeyRecorderField(binding: $binding, isRecording: $isRecording)
|
||||||
|
.frame(width: 120, height: 24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OptionalHotkeyRecorderView: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var binding: HotkeyBinding?
|
||||||
|
|
||||||
|
@State private var isRecording = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
|
||||||
|
OptionalHotkeyRecorderField(binding: $binding, isRecording: $isRecording)
|
||||||
|
.frame(width: 120, height: 24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
Button("Clear") {
|
||||||
|
binding = nil
|
||||||
|
}
|
||||||
|
.disabled(binding == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NSViewRepresentable that captures key events when focused.
|
||||||
|
struct HotkeyRecorderField: NSViewRepresentable {
|
||||||
|
@Binding var binding: HotkeyBinding
|
||||||
|
@Binding var isRecording: Bool
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> HotkeyNSView {
|
||||||
|
let view = HotkeyNSView()
|
||||||
|
view.onKeyRecorded = { newBinding in
|
||||||
|
binding = newBinding
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
view.onFocusChanged = { focused in
|
||||||
|
isRecording = focused
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
||||||
|
nsView.update(currentLabel: binding.displayString, isRecording: isRecording)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OptionalHotkeyRecorderField: NSViewRepresentable {
|
||||||
|
@Binding var binding: HotkeyBinding?
|
||||||
|
@Binding var isRecording: Bool
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> HotkeyNSView {
|
||||||
|
let view = HotkeyNSView()
|
||||||
|
view.onKeyRecorded = { newBinding in
|
||||||
|
binding = newBinding
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
view.onFocusChanged = { focused in
|
||||||
|
isRecording = focused
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
||||||
|
nsView.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual NSView that handles key capture.
|
||||||
|
class HotkeyNSView: NSView {
|
||||||
|
var currentLabel: String = ""
|
||||||
|
var showRecording: Bool = false
|
||||||
|
var onKeyRecorded: ((HotkeyBinding) -> Void)?
|
||||||
|
var onFocusChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
private let label: NSTextField = {
|
||||||
|
let field = NSTextField(labelWithString: "")
|
||||||
|
field.alignment = .center
|
||||||
|
field.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
|
||||||
|
field.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
field.backgroundColor = .clear
|
||||||
|
field.isBezeled = false
|
||||||
|
field.lineBreakMode = .byTruncatingTail
|
||||||
|
return field
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
setupLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
setupLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func layout() {
|
||||||
|
super.layout()
|
||||||
|
updateLabelAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
window?.makeFirstResponder(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
onFocusChanged?(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignFirstResponder() -> Bool {
|
||||||
|
onFocusChanged?(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
guard showRecording else {
|
||||||
|
super.keyDown(with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
|
||||||
|
let masked = event.modifierFlags.intersection(.deviceIndependentFlagsMask).intersection(relevantFlags)
|
||||||
|
|
||||||
|
// Require at least one modifier key
|
||||||
|
guard !masked.isEmpty else { return }
|
||||||
|
|
||||||
|
let binding = HotkeyBinding(modifiers: masked.rawValue, keyCode: event.keyCode)
|
||||||
|
onKeyRecorded?(binding)
|
||||||
|
|
||||||
|
// Resign first responder after recording
|
||||||
|
window?.makeFirstResponder(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupLabel() {
|
||||||
|
addSubview(label)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
|
||||||
|
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
|
||||||
|
label.centerYAnchor.constraint(equalTo: centerYAnchor)
|
||||||
|
])
|
||||||
|
updateLabelAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(currentLabel: String, isRecording: Bool) {
|
||||||
|
self.currentLabel = currentLabel
|
||||||
|
showRecording = isRecording
|
||||||
|
updateLabelAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLabelAppearance() {
|
||||||
|
label.stringValue = showRecording ? "Press keys..." : currentLabel
|
||||||
|
label.textColor = showRecording ? .controlAccentColor : .labelColor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import SwiftUI
|
|||||||
/// between the compact closed state and the expanded open state.
|
/// between the compact closed state and the expanded open state.
|
||||||
///
|
///
|
||||||
/// The shape uses quadratic Bezier curves to produce the distinctive
|
/// The shape uses quadratic Bezier curves to produce the distinctive
|
||||||
/// "ear" ramps on each side when closed, and a clean rounded-bottom
|
/// top-edge cut-ins of the closed notch, and a clean rounded-bottom
|
||||||
/// rectangle when open (topCornerRadius approaches 0).
|
/// rectangle when open (topCornerRadius approaches 0).
|
||||||
struct NotchShape: Shape {
|
struct NotchShape: Shape {
|
||||||
|
|
||||||
/// Radius applied to the top-left and top-right outer corners (the "ears").
|
/// Radius applied to the top-left and top-right transitions where the notch
|
||||||
/// When close to 0, the top corners become sharp and the shape is a
|
/// curves away from the screen edge. When close to 0, the top corners become
|
||||||
/// rectangle with rounded bottom corners — no visible ear ramps.
|
/// sharp and the shape is a rectangle with rounded bottom corners.
|
||||||
var topCornerRadius: CGFloat
|
var topCornerRadius: CGFloat
|
||||||
|
|
||||||
/// Radius applied to the bottom-left and bottom-right inner corners.
|
/// Radius applied to the bottom-left and bottom-right inner corners.
|
||||||
@@ -46,10 +46,10 @@ struct NotchShape: Shape {
|
|||||||
path.move(to: CGPoint(x: minX, y: minY))
|
path.move(to: CGPoint(x: minX, y: minY))
|
||||||
|
|
||||||
if topR > 0.5 {
|
if topR > 0.5 {
|
||||||
// Top-left ear: curve down from the top edge
|
// Leave the screen edge horizontally, then turn into the side wall.
|
||||||
path.addQuadCurve(
|
path.addQuadCurve(
|
||||||
to: CGPoint(x: minX + topR, y: minY + topR),
|
to: CGPoint(x: minX + topR, y: minY + topR),
|
||||||
control: CGPoint(x: minX, y: minY + topR)
|
control: CGPoint(x: minX + topR, y: minY)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
path.addLine(to: CGPoint(x: minX, y: minY))
|
path.addLine(to: CGPoint(x: minX, y: minY))
|
||||||
@@ -73,14 +73,14 @@ struct NotchShape: Shape {
|
|||||||
control: CGPoint(x: maxX - topR, y: maxY)
|
control: CGPoint(x: maxX - topR, y: maxY)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Right edge up to top-right ear area
|
// Right edge up to the top-right transition
|
||||||
path.addLine(to: CGPoint(x: maxX - topR, y: minY + topR))
|
path.addLine(to: CGPoint(x: maxX - topR, y: minY + topR))
|
||||||
|
|
||||||
if topR > 0.5 {
|
if topR > 0.5 {
|
||||||
// Top-right ear: curve back up to the top edge
|
// Mirror the top-left transition.
|
||||||
path.addQuadCurve(
|
path.addQuadCurve(
|
||||||
to: CGPoint(x: maxX, y: minY),
|
to: CGPoint(x: maxX, y: minY),
|
||||||
control: CGPoint(x: maxX, y: minY + topR)
|
control: CGPoint(x: maxX - topR, y: minY)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
path.addLine(to: CGPoint(x: maxX, y: minY))
|
path.addLine(to: CGPoint(x: maxX, y: minY))
|
||||||
@@ -100,8 +100,8 @@ extension NotchShape {
|
|||||||
NotchShape(topCornerRadius: 6, bottomCornerRadius: 14)
|
NotchShape(topCornerRadius: 6, bottomCornerRadius: 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open-state shape: no ear ramps, just rounded bottom corners.
|
/// Open-state shape: no top-edge cut-ins, just rounded bottom corners.
|
||||||
/// topCornerRadius is near-zero so the ears disappear and the panel
|
/// topCornerRadius is near-zero so the top becomes effectively flat and the panel
|
||||||
/// extends flush to the top edge of the screen.
|
/// extends flush to the top edge of the screen.
|
||||||
static var opened: NotchShape {
|
static var opened: NotchShape {
|
||||||
NotchShape(topCornerRadius: 0, bottomCornerRadius: 24)
|
NotchShape(topCornerRadius: 0, bottomCornerRadius: 24)
|
||||||
@@ -5,30 +5,30 @@ import SwiftUI
|
|||||||
/// the single `.opacity()` on ContentView handles transparency.
|
/// the single `.opacity()` on ContentView handles transparency.
|
||||||
struct TabBar: View {
|
struct TabBar: View {
|
||||||
|
|
||||||
@ObservedObject var terminalManager: TerminalManager
|
@ObservedObject var workspace: WorkspaceController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
ForEach(Array(terminalManager.tabs.enumerated()), id: \.element.id) { index, tab in
|
|
||||||
tabButton(for: tab, at: index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
terminalManager.newTab()
|
workspace.newTab()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("New Tab")
|
||||||
|
.accessibilityIdentifier("notch.new-tab")
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
|
||||||
|
tabButton(for: tab, at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 28)
|
.frame(height: 28)
|
||||||
.background(.black)
|
.background(.black)
|
||||||
@@ -36,7 +36,7 @@ struct TabBar: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func tabButton(for tab: TerminalSession, at index: Int) -> some View {
|
private func tabButton(for tab: TerminalSession, at index: Int) -> some View {
|
||||||
let isActive = index == terminalManager.activeTabIndex
|
let isActive = index == workspace.activeTabIndex
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(abbreviateTitle(tab.title))
|
Text(abbreviateTitle(tab.title))
|
||||||
@@ -44,9 +44,9 @@ struct TabBar: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundStyle(isActive ? .white : .white.opacity(0.5))
|
.foregroundStyle(isActive ? .white : .white.opacity(0.5))
|
||||||
|
|
||||||
if isActive && terminalManager.tabs.count > 1 {
|
if isActive && workspace.tabs.count > 1 {
|
||||||
Button {
|
Button {
|
||||||
terminalManager.closeTab(at: index)
|
workspace.closeTab(at: index)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 8, weight: .bold))
|
.font(.system(size: 8, weight: .bold))
|
||||||
@@ -63,7 +63,7 @@ struct TabBar: View {
|
|||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
terminalManager.switchToTab(at: index)
|
workspace.switchToTab(at: index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon.HIToolbox
|
||||||
|
import SwiftTerm
|
||||||
|
|
||||||
|
enum TerminalCommandArrowBehavior {
|
||||||
|
private static let relevantModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
|
||||||
|
private static let lineKill: [UInt8] = [0x15]
|
||||||
|
private static let clearScreen: [UInt8] = [0x0c]
|
||||||
|
|
||||||
|
static func sequence(
|
||||||
|
for modifierFlags: NSEvent.ModifierFlags,
|
||||||
|
keyCode: UInt16,
|
||||||
|
applicationCursor: Bool
|
||||||
|
) -> [UInt8]? {
|
||||||
|
let flags = modifierFlags.intersection(relevantModifiers)
|
||||||
|
guard flags == [.command] else { return nil }
|
||||||
|
|
||||||
|
switch Int(keyCode) {
|
||||||
|
case kVK_LeftArrow:
|
||||||
|
return applicationCursor ? EscapeSequences.moveHomeApp : EscapeSequences.moveHomeNormal
|
||||||
|
case kVK_RightArrow:
|
||||||
|
return applicationCursor ? EscapeSequences.moveEndApp : EscapeSequences.moveEndNormal
|
||||||
|
case kVK_Delete:
|
||||||
|
return lineKill
|
||||||
|
case kVK_ANSI_L:
|
||||||
|
return clearScreen
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
CommandNotch/CommandNotch/ContentView.swift
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftTerm
|
||||||
|
|
||||||
|
/// Main view rendered inside each NotchWindow.
|
||||||
|
///
|
||||||
|
/// Opacity strategy: EVERY element has a solid black background.
|
||||||
|
/// A single `.opacity(notchOpacity)` is applied at the outermost
|
||||||
|
/// level so everything becomes uniformly transparent — no double
|
||||||
|
/// layering, no mismatched areas.
|
||||||
|
struct ContentView: View {
|
||||||
|
|
||||||
|
@ObservedObject var screen: ScreenContext
|
||||||
|
let orchestrator: NotchOrchestrator
|
||||||
|
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||||
|
@ObservedObject private var screenRegistry = ScreenRegistry.shared
|
||||||
|
|
||||||
|
@State private var resizeStartSize: CGSize?
|
||||||
|
@State private var resizeStartMouseLocation: CGPoint?
|
||||||
|
|
||||||
|
private var hoverAnimation: Animation {
|
||||||
|
.interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentShape: NotchShape {
|
||||||
|
screen.notchState == .open
|
||||||
|
? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14))
|
||||||
|
: .closed
|
||||||
|
}
|
||||||
|
|
||||||
|
private var enableShadow: Bool {
|
||||||
|
settingsController.settings.appearance.enableShadow
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shadowRadius: Double {
|
||||||
|
settingsController.settings.appearance.shadowRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shadowOpacity: Double {
|
||||||
|
settingsController.settings.appearance.shadowOpacity
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cornerRadiusScaling: Bool {
|
||||||
|
settingsController.settings.appearance.cornerRadiusScaling
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notchOpacity: Double {
|
||||||
|
settingsController.settings.appearance.notchOpacity
|
||||||
|
}
|
||||||
|
|
||||||
|
private var blurRadius: Double {
|
||||||
|
settingsController.settings.appearance.blurRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hoverSpringResponse: Double {
|
||||||
|
settingsController.settings.animation.hoverSpringResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hoverSpringDamping: Double {
|
||||||
|
settingsController.settings.animation.hoverSpringDamping
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
notchBody
|
||||||
|
.accessibilityIdentifier("notch.container")
|
||||||
|
.frame(
|
||||||
|
width: screen.notchSize.width,
|
||||||
|
height: screen.notchState == .open ? screen.notchSize.height : screen.closedNotchSize.height,
|
||||||
|
alignment: .top
|
||||||
|
)
|
||||||
|
.background(.black)
|
||||||
|
.clipShape(currentShape)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(.black).frame(height: 1)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
if screen.notchState == .open {
|
||||||
|
resizeHandle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.shadow(
|
||||||
|
color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear,
|
||||||
|
radius: enableShadow ? shadowRadius : 0
|
||||||
|
)
|
||||||
|
// Single opacity control — everything inside is solid black,
|
||||||
|
// so this one modifier makes it all uniformly transparent.
|
||||||
|
.opacity(notchOpacity)
|
||||||
|
.blur(radius: blurRadius)
|
||||||
|
.animation(screen.notchState == .open ? screen.openAnimation : screen.closeAnimation, value: screen.notchState)
|
||||||
|
.animation(sizeAnimation, value: screen.notchSize.width)
|
||||||
|
.animation(sizeAnimation, value: screen.notchSize.height)
|
||||||
|
.onHover { handleHover($0) }
|
||||||
|
.onDisappear {
|
||||||
|
resizeStartSize = nil
|
||||||
|
resizeStartMouseLocation = nil
|
||||||
|
screen.endInteractiveResize()
|
||||||
|
orchestrator.handleHoverChange(false, for: screen.id)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var notchBody: some View {
|
||||||
|
WorkspaceScopedView(screen: screen, screenRegistry: screenRegistry) { workspace in
|
||||||
|
if screen.notchState == .open {
|
||||||
|
openContent(workspace: workspace)
|
||||||
|
.transition(.opacity)
|
||||||
|
} else {
|
||||||
|
closedContent(workspace: workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closedContent(workspace: WorkspaceController) -> some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text(abbreviate(workspace.activeTitle))
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.background(.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resizeHandle: some View {
|
||||||
|
ResizeHandleShape()
|
||||||
|
.stroke(.white.opacity(0.32), style: StrokeStyle(lineWidth: 1.2, lineCap: .round))
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.contentShape(Rectangle().inset(by: -8))
|
||||||
|
.gesture(resizeGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resizeGesture: some Gesture {
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { value in
|
||||||
|
if resizeStartSize == nil {
|
||||||
|
resizeStartSize = screen.notchSize
|
||||||
|
resizeStartMouseLocation = NSEvent.mouseLocation
|
||||||
|
screen.beginInteractiveResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let startSize = resizeStartSize,
|
||||||
|
let startMouseLocation = resizeStartMouseLocation else { return }
|
||||||
|
let currentMouseLocation = NSEvent.mouseLocation
|
||||||
|
screen.resizeOpenNotch(
|
||||||
|
to: CGSize(
|
||||||
|
width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2),
|
||||||
|
height: startSize.height + (startMouseLocation.y - currentMouseLocation.y)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
resizeStartSize = nil
|
||||||
|
resizeStartMouseLocation = nil
|
||||||
|
screen.endInteractiveResize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sizeAnimation: Animation? {
|
||||||
|
guard !screen.isUserResizing, !screen.isPresetResizing else { return nil }
|
||||||
|
return screen.notchState == .open ? screen.openAnimation : screen.closeAnimation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open layout: VStack with toolbar row on top, terminal in the middle,
|
||||||
|
/// tab bar at the bottom. Every section has a black background.
|
||||||
|
private func openContent(workspace: WorkspaceController) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Toolbar row — right-aligned, solid black
|
||||||
|
HStack {
|
||||||
|
WorkspaceSwitcherView(screen: screen, orchestrator: orchestrator)
|
||||||
|
Spacer()
|
||||||
|
toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") {
|
||||||
|
if let session = workspace.detachActiveTab() {
|
||||||
|
PopoutWindowController.shared.popout(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolbarButton(icon: "gearshape.fill", help: "Settings") {
|
||||||
|
SettingsWindowController.shared.showSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.trailing, 10)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
.background(.black)
|
||||||
|
|
||||||
|
// Terminal — fills remaining space
|
||||||
|
if let session = workspace.activeTab {
|
||||||
|
SwiftTermView(session: session)
|
||||||
|
.id(session.id)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.trailing, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab bar
|
||||||
|
TabBar(workspace: workspace)
|
||||||
|
}
|
||||||
|
.background(.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toolbarButton(icon: String, help: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
|
.padding(4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(help)
|
||||||
|
.accessibilityIdentifier("notch.toolbar.\(icon)")
|
||||||
|
.help(help)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hover
|
||||||
|
|
||||||
|
private func handleHover(_ hovering: Bool) {
|
||||||
|
withAnimation(hoverAnimation) {
|
||||||
|
orchestrator.handleHoverChange(hovering, for: screen.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func abbreviate(_ title: String) -> String {
|
||||||
|
title.count <= 30 ? title : String(title.prefix(28)) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ResizeHandleShape: Shape {
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: CGPoint(x: rect.maxX - 10, y: rect.maxY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 10))
|
||||||
|
path.move(to: CGPoint(x: rect.maxX - 6, y: rect.maxY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 6))
|
||||||
|
path.move(to: CGPoint(x: rect.maxX - 2, y: rect.maxY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 2))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WorkspaceScopedView<Content: View>: View {
|
||||||
|
@ObservedObject var screen: ScreenContext
|
||||||
|
@ObservedObject var screenRegistry: ScreenRegistry
|
||||||
|
let content: (WorkspaceController) -> Content
|
||||||
|
|
||||||
|
init(
|
||||||
|
screen: ScreenContext,
|
||||||
|
screenRegistry: ScreenRegistry,
|
||||||
|
@ViewBuilder content: @escaping (WorkspaceController) -> Content
|
||||||
|
) {
|
||||||
|
self.screen = screen
|
||||||
|
self.screenRegistry = screenRegistry
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
WorkspaceObservedView(workspace: screenRegistry.workspaceController(for: screen.id), content: content)
|
||||||
|
.id(screen.workspaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WorkspaceObservedView<Content: View>: View {
|
||||||
|
@ObservedObject var workspace: WorkspaceController
|
||||||
|
let content: (WorkspaceController) -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content(workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,18 +27,16 @@ extension NSScreen {
|
|||||||
|
|
||||||
/// Computes the closed-state notch size for this screen,
|
/// Computes the closed-state notch size for this screen,
|
||||||
/// respecting the user's height mode and custom height preferences.
|
/// respecting the user's height mode and custom height preferences.
|
||||||
func closedNotchSize() -> CGSize {
|
func closedNotchSize(using settings: AppSettings.DisplaySettings) -> CGSize {
|
||||||
let height = closedNotchHeight()
|
let height = closedNotchHeight(using: settings)
|
||||||
let width = closedNotchWidth()
|
let width = closedNotchWidth()
|
||||||
return CGSize(width: width, height: height)
|
return CGSize(width: width, height: height)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Height of the closed notch bar, determined by the user's chosen mode.
|
/// Height of the closed notch bar, determined by the user's chosen mode.
|
||||||
private func closedNotchHeight() -> CGFloat {
|
private func closedNotchHeight(using settings: AppSettings.DisplaySettings) -> CGFloat {
|
||||||
let defaults = UserDefaults.standard
|
|
||||||
|
|
||||||
if hasNotch {
|
if hasNotch {
|
||||||
let mode = NotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.notchHeightMode))
|
let mode = NotchHeightMode(rawValue: settings.notchHeightMode)
|
||||||
?? .matchRealNotchSize
|
?? .matchRealNotchSize
|
||||||
switch mode {
|
switch mode {
|
||||||
case .matchRealNotchSize:
|
case .matchRealNotchSize:
|
||||||
@@ -46,16 +44,16 @@ extension NSScreen {
|
|||||||
case .matchMenuBar:
|
case .matchMenuBar:
|
||||||
return menuBarHeight()
|
return menuBarHeight()
|
||||||
case .custom:
|
case .custom:
|
||||||
return defaults.double(forKey: NotchSettings.Keys.notchHeight)
|
return settings.notchHeight
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let mode = NonNotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.nonNotchHeightMode))
|
let mode = NonNotchHeightMode(rawValue: settings.nonNotchHeightMode)
|
||||||
?? .matchMenuBar
|
?? .matchMenuBar
|
||||||
switch mode {
|
switch mode {
|
||||||
case .matchMenuBar:
|
case .matchMenuBar:
|
||||||
return menuBarHeight()
|
return menuBarHeight()
|
||||||
case .custom:
|
case .custom:
|
||||||
return defaults.double(forKey: NotchSettings.Keys.nonNotchHeight)
|
return settings.nonNotchHeight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Carbon.HIToolbox
|
import Carbon.HIToolbox
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// Manages global and local hotkeys.
|
/// Manages global and local hotkeys.
|
||||||
///
|
///
|
||||||
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
|
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
|
||||||
/// system-wide without Accessibility permission. Tab-level hotkeys
|
/// system-wide without Accessibility permission. Notch-scoped hotkeys
|
||||||
/// use a local `NSEvent` monitor (only fires when our app is active).
|
/// use a local `NSEvent` monitor (only fires when our app is active).
|
||||||
|
@MainActor
|
||||||
class HotkeyManager {
|
class HotkeyManager {
|
||||||
|
|
||||||
static let shared = HotkeyManager()
|
static let shared = HotkeyManager()
|
||||||
@@ -17,43 +19,59 @@ class HotkeyManager {
|
|||||||
var onCloseTab: (() -> Void)?
|
var onCloseTab: (() -> Void)?
|
||||||
var onNextTab: (() -> Void)?
|
var onNextTab: (() -> Void)?
|
||||||
var onPreviousTab: (() -> Void)?
|
var onPreviousTab: (() -> Void)?
|
||||||
|
var onNextWorkspace: (() -> Void)?
|
||||||
|
var onPreviousWorkspace: (() -> Void)?
|
||||||
var onDetachTab: (() -> Void)?
|
var onDetachTab: (() -> Void)?
|
||||||
|
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
|
||||||
var onSwitchToTab: ((Int) -> Void)?
|
var onSwitchToTab: ((Int) -> Void)?
|
||||||
|
var onSwitchToWorkspace: ((WorkspaceID) -> Void)?
|
||||||
|
|
||||||
/// Tab-level hotkeys only fire when the notch is open.
|
/// Notch-scoped hotkeys only fire when the notch is open.
|
||||||
var isNotchOpen: Bool = false
|
var isNotchOpen: Bool = false
|
||||||
|
|
||||||
private var hotKeyRef: EventHotKeyRef?
|
private var hotKeyRef: EventHotKeyRef?
|
||||||
private var eventHandlerRef: EventHandlerRef?
|
private var eventHandlerRef: EventHandlerRef?
|
||||||
private var localMonitor: Any?
|
private var localMonitor: Any?
|
||||||
private var defaultsObserver: NSObjectProtocol?
|
private let settingsProvider: TerminalSessionConfigurationProviding
|
||||||
|
private let workspaceRegistry: WorkspaceRegistry
|
||||||
|
private var settingsCancellable: AnyCancellable?
|
||||||
|
|
||||||
private init() {}
|
init(
|
||||||
|
settingsProvider: TerminalSessionConfigurationProviding? = nil,
|
||||||
|
workspaceRegistry: WorkspaceRegistry? = nil
|
||||||
|
) {
|
||||||
|
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
|
||||||
|
self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Resolved bindings (live from UserDefaults)
|
// MARK: - Resolved bindings from typed runtime settings
|
||||||
|
|
||||||
private var toggleBinding: HotkeyBinding {
|
private var toggleBinding: HotkeyBinding {
|
||||||
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn
|
settingsProvider.hotkeySettings.toggle
|
||||||
}
|
}
|
||||||
private var newTabBinding: HotkeyBinding {
|
private var newTabBinding: HotkeyBinding {
|
||||||
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT
|
settingsProvider.hotkeySettings.newTab
|
||||||
}
|
}
|
||||||
private var closeTabBinding: HotkeyBinding {
|
private var closeTabBinding: HotkeyBinding {
|
||||||
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW
|
settingsProvider.hotkeySettings.closeTab
|
||||||
}
|
}
|
||||||
private var nextTabBinding: HotkeyBinding {
|
private var nextTabBinding: HotkeyBinding {
|
||||||
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB
|
settingsProvider.hotkeySettings.nextTab
|
||||||
}
|
}
|
||||||
private var prevTabBinding: HotkeyBinding {
|
private var prevTabBinding: HotkeyBinding {
|
||||||
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB
|
settingsProvider.hotkeySettings.previousTab
|
||||||
|
}
|
||||||
|
private var nextWorkspaceBinding: HotkeyBinding {
|
||||||
|
settingsProvider.hotkeySettings.nextWorkspace
|
||||||
|
}
|
||||||
|
private var previousWorkspaceBinding: HotkeyBinding {
|
||||||
|
settingsProvider.hotkeySettings.previousWorkspace
|
||||||
}
|
}
|
||||||
private var detachBinding: HotkeyBinding {
|
private var detachBinding: HotkeyBinding {
|
||||||
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
|
settingsProvider.hotkeySettings.detachTab
|
||||||
}
|
}
|
||||||
|
private var sizePresets: [TerminalSizePreset] {
|
||||||
private func binding(for key: String) -> HotkeyBinding? {
|
settingsProvider.terminalSizePresets
|
||||||
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
|
|
||||||
return HotkeyBinding.fromJSON(json)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Start / Stop
|
// MARK: - Start / Stop
|
||||||
@@ -69,10 +87,7 @@ class HotkeyManager {
|
|||||||
unregisterToggleHotkey()
|
unregisterToggleHotkey()
|
||||||
removeCarbonHandler()
|
removeCarbonHandler()
|
||||||
removeLocalMonitor()
|
removeLocalMonitor()
|
||||||
if let obs = defaultsObserver {
|
settingsCancellable = nil
|
||||||
NotificationCenter.default.removeObserver(obs)
|
|
||||||
defaultsObserver = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Carbon global hotkey (toggle)
|
// MARK: - Carbon global hotkey (toggle)
|
||||||
@@ -126,7 +141,7 @@ class HotkeyManager {
|
|||||||
|
|
||||||
let binding = toggleBinding
|
let binding = toggleBinding
|
||||||
let carbonMods = carbonModifiers(from: binding.modifiers)
|
let carbonMods = carbonModifiers(from: binding.modifiers)
|
||||||
var hotKeyID = EventHotKeyID(
|
let hotKeyID = EventHotKeyID(
|
||||||
signature: OSType(0x444E5452), // "DNTR"
|
signature: OSType(0x444E5452), // "DNTR"
|
||||||
id: 1
|
id: 1
|
||||||
)
|
)
|
||||||
@@ -159,18 +174,20 @@ class HotkeyManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-register the toggle hotkey whenever the user changes it in settings.
|
/// Re-register the toggle hotkey whenever the typed settings change.
|
||||||
private func observeToggleHotkeyChanges() {
|
private func observeToggleHotkeyChanges() {
|
||||||
defaultsObserver = NotificationCenter.default.addObserver(
|
guard let settingsProvider = settingsProvider as? AppSettingsController else { return }
|
||||||
forName: UserDefaults.didChangeNotification,
|
|
||||||
object: nil,
|
settingsCancellable = settingsProvider.$settings
|
||||||
queue: .main
|
.map(\.hotkeys.toggle)
|
||||||
) { [weak self] _ in
|
.removeDuplicates()
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] _ in
|
||||||
self?.registerToggleHotkey()
|
self?.registerToggleHotkey()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)
|
// MARK: - Local monitor (notch-level hotkeys, only when our app is active)
|
||||||
|
|
||||||
private func installLocalMonitor() {
|
private func installLocalMonitor() {
|
||||||
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||||
@@ -186,9 +203,9 @@ class HotkeyManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles tab-level hotkeys. Returns true if the event was consumed.
|
/// Handles notch-scoped hotkeys. Returns true if the event was consumed.
|
||||||
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
|
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
|
||||||
// Tab hotkeys only when the notch is open and focused
|
// Local shortcuts only fire when the notch is open and focused.
|
||||||
guard isNotchOpen else { return false }
|
guard isNotchOpen else { return false }
|
||||||
|
|
||||||
if newTabBinding.matches(event) {
|
if newTabBinding.matches(event) {
|
||||||
@@ -207,10 +224,32 @@ class HotkeyManager {
|
|||||||
onPreviousTab?()
|
onPreviousTab?()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if nextWorkspaceBinding.matches(event) {
|
||||||
|
onNextWorkspace?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if previousWorkspaceBinding.matches(event) {
|
||||||
|
onPreviousWorkspace?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if detachBinding.matches(event) {
|
if detachBinding.matches(event) {
|
||||||
onDetachTab?()
|
onDetachTab?()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
for summary in workspaceRegistry.workspaceSummaries {
|
||||||
|
guard let binding = summary.hotkey else { continue }
|
||||||
|
if binding.matches(event) {
|
||||||
|
onSwitchToWorkspace?(summary.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for preset in sizePresets {
|
||||||
|
guard let binding = preset.hotkey else { continue }
|
||||||
|
if binding.matches(event) {
|
||||||
|
onApplySizePreset?(preset)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cmd+1 through Cmd+9
|
// Cmd+1 through Cmd+9
|
||||||
if event.modifierFlags.contains(.command) {
|
if event.modifierFlags.contains(.command) {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftTerm
|
import Combine
|
||||||
|
|
||||||
/// Manages standalone pop-out terminal windows for detached tabs.
|
/// Manages standalone pop-out terminal windows for detached tabs.
|
||||||
/// Each detached tab gets its own resizable window with the terminal view.
|
/// Each detached tab gets its own resizable window with the terminal view.
|
||||||
@@ -12,6 +12,7 @@ class PopoutWindowController: NSObject, NSWindowDelegate {
|
|||||||
/// Tracks open pop-out windows so they aren't released prematurely.
|
/// Tracks open pop-out windows so they aren't released prematurely.
|
||||||
private var windows: [UUID: NSWindow] = [:]
|
private var windows: [UUID: NSWindow] = [:]
|
||||||
private var sessions: [UUID: TerminalSession] = [:]
|
private var sessions: [UUID: TerminalSession] = [:]
|
||||||
|
private var titleObservers: [UUID: AnyCancellable] = [:]
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -21,6 +22,12 @@ class PopoutWindowController: NSObject, NSWindowDelegate {
|
|||||||
func popout(session: TerminalSession) {
|
func popout(session: TerminalSession) {
|
||||||
let windowID = session.id
|
let windowID = session.id
|
||||||
|
|
||||||
|
if let existingWindow = windows[windowID] {
|
||||||
|
existingWindow.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let win = NSWindow(
|
let win = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 720, height: 480),
|
contentRect: NSRect(x: 0, y: 0, width: 720, height: 480),
|
||||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||||
@@ -33,12 +40,14 @@ class PopoutWindowController: NSObject, NSWindowDelegate {
|
|||||||
win.delegate = self
|
win.delegate = self
|
||||||
win.isReleasedWhenClosed = false
|
win.isReleasedWhenClosed = false
|
||||||
|
|
||||||
// Embed the terminal view directly
|
let hostingView = NSHostingView(
|
||||||
let tv = session.terminalView
|
rootView: SwiftTermView(session: session)
|
||||||
tv.removeFromSuperview()
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
tv.frame = NSRect(origin: .zero, size: win.contentView!.bounds.size)
|
.background(Color.black)
|
||||||
tv.autoresizingMask = [.width, .height]
|
.preferredColorScheme(.dark)
|
||||||
win.contentView?.addSubview(tv)
|
)
|
||||||
|
hostingView.frame = NSRect(origin: .zero, size: win.contentRect(forFrameRect: win.frame).size)
|
||||||
|
win.contentView = hostingView
|
||||||
|
|
||||||
win.center()
|
win.center()
|
||||||
win.makeKeyAndOrderFront(nil)
|
win.makeKeyAndOrderFront(nil)
|
||||||
@@ -48,16 +57,22 @@ class PopoutWindowController: NSObject, NSWindowDelegate {
|
|||||||
sessions[windowID] = session
|
sessions[windowID] = session
|
||||||
|
|
||||||
// Update window title when the terminal title changes
|
// Update window title when the terminal title changes
|
||||||
session.$title
|
titleObservers[windowID] = session.$title
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.sink { [weak win] title in win?.title = title }
|
.sink { [weak win] title in win?.title = title }
|
||||||
.store(in: &popoutCancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var popoutCancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
// MARK: - NSWindowDelegate
|
// MARK: - NSWindowDelegate
|
||||||
|
|
||||||
|
func windowDidBecomeKey(_ notification: Notification) {
|
||||||
|
guard let window = notification.object as? NSWindow,
|
||||||
|
let entry = windows.first(where: { $0.value === window }),
|
||||||
|
let terminalView = sessions[entry.key]?.terminalView,
|
||||||
|
terminalView.window === window else { return }
|
||||||
|
|
||||||
|
window.makeFirstResponder(terminalView)
|
||||||
|
}
|
||||||
|
|
||||||
func windowWillClose(_ notification: Notification) {
|
func windowWillClose(_ notification: Notification) {
|
||||||
guard let closingWindow = notification.object as? NSWindow else { return }
|
guard let closingWindow = notification.object as? NSWindow else { return }
|
||||||
|
|
||||||
@@ -66,8 +81,7 @@ class PopoutWindowController: NSObject, NSWindowDelegate {
|
|||||||
sessions[entry.key]?.terminate()
|
sessions[entry.key]?.terminate()
|
||||||
sessions.removeValue(forKey: entry.key)
|
sessions.removeValue(forKey: entry.key)
|
||||||
windows.removeValue(forKey: entry.key)
|
windows.removeValue(forKey: entry.key)
|
||||||
|
titleObservers.removeValue(forKey: entry.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import Combine
|
|
||||||
297
CommandNotch/CommandNotch/Managers/ScreenManager.swift
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Coordinates screen/workspace state with notch lifecycle and
|
||||||
|
/// delegates raw window work to `WindowCoordinator`.
|
||||||
|
@MainActor
|
||||||
|
final class ScreenManager: ObservableObject {
|
||||||
|
static let shared = ScreenManager()
|
||||||
|
|
||||||
|
private let screenRegistry = ScreenRegistry.shared
|
||||||
|
private let workspaceRegistry = WorkspaceRegistry.shared
|
||||||
|
private let windowCoordinator = WindowCoordinator()
|
||||||
|
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private var showOnAllDisplays: Bool {
|
||||||
|
AppSettingsController.shared.settings.display.showOnAllDisplays
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
screenRegistry.refreshConnectedScreens()
|
||||||
|
observeScreenChanges()
|
||||||
|
rebuildWindows()
|
||||||
|
setupHotkeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
cleanupAllWindows()
|
||||||
|
cancellables.removeAll()
|
||||||
|
HotkeyManager.shared.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hotkey wiring
|
||||||
|
|
||||||
|
private func setupHotkeys() {
|
||||||
|
let hotkeyManager = HotkeyManager.shared
|
||||||
|
|
||||||
|
hotkeyManager.onToggle = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.orchestrator.toggleOnActiveScreen() }
|
||||||
|
}
|
||||||
|
hotkeyManager.onNewTab = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.activeWorkspace().newTab() }
|
||||||
|
}
|
||||||
|
hotkeyManager.onCloseTab = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.activeWorkspace().closeActiveTab() }
|
||||||
|
}
|
||||||
|
hotkeyManager.onNextTab = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.activeWorkspace().nextTab() }
|
||||||
|
}
|
||||||
|
hotkeyManager.onPreviousTab = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
|
||||||
|
}
|
||||||
|
hotkeyManager.onNextWorkspace = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) }
|
||||||
|
}
|
||||||
|
hotkeyManager.onPreviousWorkspace = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) }
|
||||||
|
}
|
||||||
|
hotkeyManager.onDetachTab = { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.detachActiveTab() }
|
||||||
|
}
|
||||||
|
hotkeyManager.onApplySizePreset = { [weak self] preset in
|
||||||
|
MainActor.assumeIsolated { self?.applySizePreset(preset) }
|
||||||
|
}
|
||||||
|
hotkeyManager.onSwitchToTab = { [weak self] index in
|
||||||
|
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
|
||||||
|
}
|
||||||
|
hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in
|
||||||
|
MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
hotkeyManager.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggle
|
||||||
|
|
||||||
|
func toggleNotchOnActiveScreen() {
|
||||||
|
orchestrator.toggleOnActiveScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open / Close
|
||||||
|
|
||||||
|
func openNotch(screenID: ScreenID) {
|
||||||
|
orchestrator.open(screenID: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeNotch(screenID: ScreenID) {
|
||||||
|
orchestrator.close(screenID: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detachActiveTab() {
|
||||||
|
if let session = activeWorkspace().detachActiveTab() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
PopoutWindowController.shared.popout(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func switchWorkspace(offset: Int) {
|
||||||
|
guard let screenID = screenRegistry.activeScreenID() else { return }
|
||||||
|
let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
|
||||||
|
let nextWorkspaceID = offset >= 0
|
||||||
|
? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID)
|
||||||
|
: workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID)
|
||||||
|
|
||||||
|
guard let nextWorkspaceID else { return }
|
||||||
|
switchScreen(screenID, to: nextWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func switchActiveScreen(to workspaceID: WorkspaceID) {
|
||||||
|
guard let screenID = screenRegistry.activeScreenID() else { return }
|
||||||
|
switchScreen(screenID, to: workspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) {
|
||||||
|
screenRegistry.assignWorkspace(workspaceID, to: screenID)
|
||||||
|
|
||||||
|
guard let context = screenRegistry.screenContext(for: screenID),
|
||||||
|
context.notchState == .open else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orchestrator.open(screenID: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySizePreset(_ preset: TerminalSizePreset) {
|
||||||
|
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
|
||||||
|
AppSettingsController.shared.update {
|
||||||
|
$0.display.openWidth = preset.width
|
||||||
|
$0.display.openHeight = preset.height
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let startSize = context.notchSize
|
||||||
|
let targetSize = context.setStoredOpenSize(preset.size)
|
||||||
|
windowCoordinator.animatePresetResize(
|
||||||
|
for: context.id,
|
||||||
|
context: context,
|
||||||
|
from: startSize,
|
||||||
|
to: targetSize,
|
||||||
|
duration: context.openAnimationDuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window creation
|
||||||
|
|
||||||
|
func rebuildWindows() {
|
||||||
|
cleanupAllWindows()
|
||||||
|
screenRegistry.refreshConnectedScreens()
|
||||||
|
|
||||||
|
for screen in visibleScreens() {
|
||||||
|
createWindow(for: screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindow(for screen: NSScreen) {
|
||||||
|
let screenID = screen.displayUUID
|
||||||
|
guard let context = screenRegistry.screenContext(for: screenID) else { return }
|
||||||
|
|
||||||
|
context.requestOpen = { [weak self] in
|
||||||
|
self?.orchestrator.open(screenID: screenID)
|
||||||
|
}
|
||||||
|
context.requestClose = { [weak self] in
|
||||||
|
self?.orchestrator.close(screenID: screenID)
|
||||||
|
}
|
||||||
|
context.requestWindowResize = { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
let context = self.screenRegistry.screenContext(for: screenID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.windowCoordinator.updateWindowFrame(
|
||||||
|
for: screenID,
|
||||||
|
context: context,
|
||||||
|
centerHorizontally: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.requestTerminalFocus = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in
|
||||||
|
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostingView = NSHostingView(
|
||||||
|
rootView: ContentView(
|
||||||
|
screen: context,
|
||||||
|
orchestrator: orchestrator
|
||||||
|
)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
)
|
||||||
|
|
||||||
|
windowCoordinator.createWindow(
|
||||||
|
on: screen,
|
||||||
|
context: context,
|
||||||
|
contentView: hostingView,
|
||||||
|
onResignKey: { [weak self] in
|
||||||
|
guard !context.suppressCloseOnFocusLoss else { return }
|
||||||
|
self?.orchestrator.close(screenID: screenID)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Repositioning
|
||||||
|
|
||||||
|
func repositionWindows() {
|
||||||
|
screenRegistry.refreshConnectedScreens()
|
||||||
|
|
||||||
|
for context in screenRegistry.allScreens() {
|
||||||
|
context.refreshClosedSize()
|
||||||
|
windowCoordinator.repositionWindow(
|
||||||
|
for: context.id,
|
||||||
|
context: context,
|
||||||
|
centerHorizontally: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
private func cleanupAllWindows() {
|
||||||
|
orchestrator.cancelAllPendingWork()
|
||||||
|
windowCoordinator.cleanupAllWindows()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Screen observation
|
||||||
|
|
||||||
|
private func observeScreenChanges() {
|
||||||
|
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
||||||
|
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.handleScreenConfigurationChange()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScreenConfigurationChange() {
|
||||||
|
screenRegistry.refreshConnectedScreens()
|
||||||
|
|
||||||
|
let currentScreenIDs = Set(visibleScreens().map(\.displayUUID))
|
||||||
|
let knownScreenIDs = windowCoordinator.windowScreenIDs()
|
||||||
|
|
||||||
|
if currentScreenIDs != knownScreenIDs {
|
||||||
|
rebuildWindows()
|
||||||
|
} else {
|
||||||
|
repositionWindows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activeWorkspace() -> WorkspaceController {
|
||||||
|
guard let screenID = screenRegistry.activeScreenID() else {
|
||||||
|
return WorkspaceRegistry.shared.defaultWorkspaceController
|
||||||
|
}
|
||||||
|
|
||||||
|
return screenRegistry.workspaceController(for: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func visibleScreens() -> [NSScreen] {
|
||||||
|
if showOnAllDisplays {
|
||||||
|
return NSScreen.screens
|
||||||
|
}
|
||||||
|
|
||||||
|
return [NSScreen.main].compactMap { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ScreenManager: NotchPresentationHost {
|
||||||
|
func canPresentNotch(for screenID: ScreenID) -> Bool {
|
||||||
|
windowCoordinator.hasWindow(for: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performOpenPresentation(for screenID: ScreenID) {
|
||||||
|
guard screenRegistry.screenContext(for: screenID) != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
windowCoordinator.presentOpen(for: screenID) { [weak self] in
|
||||||
|
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func performClosePresentation(for screenID: ScreenID) {
|
||||||
|
guard screenRegistry.screenContext(for: screenID) != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
windowCoordinator.presentClose(for: screenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
win.title = "Downterm Settings"
|
win.title = "CommandNotch Settings"
|
||||||
|
win.identifier = NSUserInterfaceItemIdentifier("settings.window")
|
||||||
win.contentView = hostingView
|
win.contentView = hostingView
|
||||||
win.center()
|
win.center()
|
||||||
win.delegate = self
|
win.delegate = self
|
||||||
291
CommandNotch/CommandNotch/Managers/WindowCoordinator.swift
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import AppKit
|
||||||
|
import QuartzCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WindowFrameCalculator {
|
||||||
|
static let horizontalPadding: CGFloat = 40
|
||||||
|
static let verticalPadding: CGFloat = 20
|
||||||
|
|
||||||
|
static func targetFrame(
|
||||||
|
screenFrame: CGRect,
|
||||||
|
currentWindowFrame: CGRect,
|
||||||
|
notchState: NotchState,
|
||||||
|
contentSize: CGSize,
|
||||||
|
centerHorizontally: Bool
|
||||||
|
) -> CGRect {
|
||||||
|
let windowWidth = contentSize.width + horizontalPadding
|
||||||
|
let windowHeight = contentSize.height + verticalPadding
|
||||||
|
let centeredX = screenFrame.origin.x + ((screenFrame.width - windowWidth) / 2)
|
||||||
|
|
||||||
|
let x: CGFloat
|
||||||
|
if centerHorizontally || notchState == .closed {
|
||||||
|
x = centeredX
|
||||||
|
} else {
|
||||||
|
x = min(
|
||||||
|
max(currentWindowFrame.minX, screenFrame.minX),
|
||||||
|
screenFrame.maxX - windowWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CGRect(
|
||||||
|
x: x,
|
||||||
|
y: screenFrame.origin.y + screenFrame.height - windowHeight,
|
||||||
|
width: windowWidth,
|
||||||
|
height: windowHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WindowCoordinator {
|
||||||
|
private let focusRetryDelay: TimeInterval
|
||||||
|
private let presetResizeFrameInterval: TimeInterval
|
||||||
|
private let screenLookup: @MainActor (ScreenID) -> NSScreen?
|
||||||
|
private let applicationActivator: @MainActor () -> Void
|
||||||
|
private let hotkeyOpenStateHandler: @MainActor (Bool) -> Void
|
||||||
|
|
||||||
|
private(set) var windows: [ScreenID: NotchWindow] = [:]
|
||||||
|
private var presetResizeTimers: [ScreenID: Timer] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
focusRetryDelay: TimeInterval = 0.01,
|
||||||
|
presetResizeFrameInterval: TimeInterval = 1.0 / 60.0,
|
||||||
|
screenLookup: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
|
||||||
|
NSScreen.screens.first { $0.displayUUID == screenID }
|
||||||
|
},
|
||||||
|
applicationActivator: @escaping @MainActor () -> Void = {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
},
|
||||||
|
hotkeyOpenStateHandler: @escaping @MainActor (Bool) -> Void = { isOpen in
|
||||||
|
HotkeyManager.shared.isNotchOpen = isOpen
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
self.focusRetryDelay = focusRetryDelay
|
||||||
|
self.presetResizeFrameInterval = presetResizeFrameInterval
|
||||||
|
self.screenLookup = screenLookup
|
||||||
|
self.applicationActivator = applicationActivator
|
||||||
|
self.hotkeyOpenStateHandler = hotkeyOpenStateHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasWindow(for screenID: ScreenID) -> Bool {
|
||||||
|
windows[screenID] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowScreenIDs() -> Set<ScreenID> {
|
||||||
|
Set(windows.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWindow(
|
||||||
|
on screen: NSScreen,
|
||||||
|
context: ScreenContext,
|
||||||
|
contentView: NSView,
|
||||||
|
onResignKey: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let initialFrame = WindowFrameCalculator.targetFrame(
|
||||||
|
screenFrame: screen.frame,
|
||||||
|
currentWindowFrame: .zero,
|
||||||
|
notchState: context.notchState,
|
||||||
|
contentSize: context.openNotchSize,
|
||||||
|
centerHorizontally: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let window = NotchWindow(
|
||||||
|
contentRect: initialFrame,
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.onResignKey = onResignKey
|
||||||
|
|
||||||
|
let containerView = NSView(frame: NSRect(origin: .zero, size: initialFrame.size))
|
||||||
|
containerView.autoresizesSubviews = true
|
||||||
|
containerView.wantsLayer = true
|
||||||
|
containerView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
|
||||||
|
contentView.frame = containerView.bounds
|
||||||
|
contentView.autoresizingMask = [.width, .height]
|
||||||
|
containerView.addSubview(contentView)
|
||||||
|
window.contentView = containerView
|
||||||
|
|
||||||
|
windows[context.id] = window
|
||||||
|
|
||||||
|
updateWindowFrame(for: context.id, context: context, centerHorizontally: true)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositionWindow(for screenID: ScreenID, context: ScreenContext, centerHorizontally: Bool = false) {
|
||||||
|
updateWindowFrame(for: screenID, context: context, centerHorizontally: centerHorizontally)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWindowFrame(
|
||||||
|
for screenID: ScreenID,
|
||||||
|
context: ScreenContext,
|
||||||
|
contentSize: CGSize? = nil,
|
||||||
|
centerHorizontally: Bool = false
|
||||||
|
) {
|
||||||
|
guard let screen = screenLookup(screenID),
|
||||||
|
let window = windows[screenID] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = WindowFrameCalculator.targetFrame(
|
||||||
|
screenFrame: screen.frame,
|
||||||
|
currentWindowFrame: window.frame,
|
||||||
|
notchState: context.notchState,
|
||||||
|
contentSize: resolvedContentSize(for: context, override: contentSize),
|
||||||
|
centerHorizontally: centerHorizontally
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !window.frame.equalTo(frame) else { return }
|
||||||
|
window.setFrame(frame, display: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func animatePresetResize(
|
||||||
|
for screenID: ScreenID,
|
||||||
|
context: ScreenContext,
|
||||||
|
from startSize: CGSize,
|
||||||
|
to targetSize: CGSize,
|
||||||
|
duration: TimeInterval
|
||||||
|
) {
|
||||||
|
cancelPresetResize(for: screenID)
|
||||||
|
|
||||||
|
guard startSize != targetSize else {
|
||||||
|
context.notchSize = targetSize
|
||||||
|
updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.isPresetResizing = true
|
||||||
|
let startTime = CACurrentMediaTime()
|
||||||
|
let frameInterval = max(duration, presetResizeFrameInterval)
|
||||||
|
|
||||||
|
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self else {
|
||||||
|
timer.invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = CACurrentMediaTime() - startTime
|
||||||
|
let progress = min(1, elapsed / frameInterval)
|
||||||
|
let easedProgress = 0.5 - (cos(.pi * progress) / 2)
|
||||||
|
let size = CGSize(
|
||||||
|
width: startSize.width + ((targetSize.width - startSize.width) * easedProgress),
|
||||||
|
height: startSize.height + ((targetSize.height - startSize.height) * easedProgress)
|
||||||
|
)
|
||||||
|
|
||||||
|
context.notchSize = size
|
||||||
|
self.updateWindowFrame(for: screenID, context: context, contentSize: size, centerHorizontally: true)
|
||||||
|
|
||||||
|
if progress >= 1 {
|
||||||
|
context.notchSize = targetSize
|
||||||
|
context.isPresetResizing = false
|
||||||
|
self.updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
|
||||||
|
self.presetResizeTimers[screenID] = nil
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
presetResizeTimers[screenID] = timer
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
timer.fire()
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentOpen(
|
||||||
|
for screenID: ScreenID,
|
||||||
|
terminalViewProvider: @escaping @MainActor () -> NSView?
|
||||||
|
) {
|
||||||
|
guard let window = windows[screenID] else { return }
|
||||||
|
|
||||||
|
window.isNotchOpen = true
|
||||||
|
updateHotkeyOpenState()
|
||||||
|
applicationActivator()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
|
focusActiveTerminal(
|
||||||
|
in: screenID,
|
||||||
|
attemptsRemaining: 12,
|
||||||
|
terminalViewProvider: terminalViewProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func focusActiveTerminal(
|
||||||
|
for screenID: ScreenID,
|
||||||
|
terminalViewProvider: @escaping @MainActor () -> NSView?
|
||||||
|
) {
|
||||||
|
focusActiveTerminal(
|
||||||
|
in: screenID,
|
||||||
|
attemptsRemaining: 12,
|
||||||
|
terminalViewProvider: terminalViewProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentClose(for screenID: ScreenID) {
|
||||||
|
guard let window = windows[screenID] else { return }
|
||||||
|
|
||||||
|
window.isNotchOpen = false
|
||||||
|
updateHotkeyOpenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupAllWindows() {
|
||||||
|
for timer in presetResizeTimers.values {
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
presetResizeTimers.removeAll()
|
||||||
|
|
||||||
|
for window in windows.values {
|
||||||
|
window.orderOut(nil)
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
windows.removeAll()
|
||||||
|
updateHotkeyOpenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func focusActiveTerminal(
|
||||||
|
in screenID: ScreenID,
|
||||||
|
attemptsRemaining: Int,
|
||||||
|
terminalViewProvider: @escaping @MainActor () -> NSView?
|
||||||
|
) {
|
||||||
|
guard let window = windows[screenID],
|
||||||
|
let terminalView = terminalViewProvider() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if terminalView.window === window {
|
||||||
|
window.makeFirstResponder(terminalView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard attemptsRemaining > 0 else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.focusActiveTerminal(
|
||||||
|
in: screenID,
|
||||||
|
attemptsRemaining: attemptsRemaining - 1,
|
||||||
|
terminalViewProvider: terminalViewProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPresetResize(for screenID: ScreenID) {
|
||||||
|
presetResizeTimers[screenID]?.invalidate()
|
||||||
|
presetResizeTimers[screenID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedContentSize(for context: ScreenContext, override: CGSize?) -> CGSize {
|
||||||
|
if let override {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.notchState == .open ? context.notchSize : context.openNotchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHotkeyOpenState() {
|
||||||
|
hotkeyOpenStateHandler(windows.values.contains(where: \.isNotchOpen))
|
||||||
|
}
|
||||||
|
}
|
||||||
165
CommandNotch/CommandNotch/Models/AppSettings.swift
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
struct AppSettings: Equatable, Codable {
|
||||||
|
var display: DisplaySettings
|
||||||
|
var behavior: BehaviorSettings
|
||||||
|
var appearance: AppearanceSettings
|
||||||
|
var animation: AnimationSettings
|
||||||
|
var terminal: TerminalSettings
|
||||||
|
var hotkeys: HotkeySettings
|
||||||
|
|
||||||
|
static let `default` = AppSettings(
|
||||||
|
display: DisplaySettings(
|
||||||
|
showOnAllDisplays: NotchSettings.Defaults.showOnAllDisplays,
|
||||||
|
showMenuBarIcon: NotchSettings.Defaults.showMenuBarIcon,
|
||||||
|
launchAtLogin: NotchSettings.Defaults.launchAtLogin,
|
||||||
|
notchHeightMode: NotchSettings.Defaults.notchHeightMode,
|
||||||
|
notchHeight: NotchSettings.Defaults.notchHeight,
|
||||||
|
nonNotchHeightMode: NotchSettings.Defaults.nonNotchHeightMode,
|
||||||
|
nonNotchHeight: NotchSettings.Defaults.nonNotchHeight,
|
||||||
|
openWidth: NotchSettings.Defaults.openWidth,
|
||||||
|
openHeight: NotchSettings.Defaults.openHeight
|
||||||
|
),
|
||||||
|
behavior: BehaviorSettings(
|
||||||
|
openNotchOnHover: NotchSettings.Defaults.openNotchOnHover,
|
||||||
|
minimumHoverDuration: NotchSettings.Defaults.minimumHoverDuration,
|
||||||
|
enableGestures: NotchSettings.Defaults.enableGestures,
|
||||||
|
gestureSensitivity: NotchSettings.Defaults.gestureSensitivity
|
||||||
|
),
|
||||||
|
appearance: AppearanceSettings(
|
||||||
|
enableShadow: NotchSettings.Defaults.enableShadow,
|
||||||
|
shadowRadius: NotchSettings.Defaults.shadowRadius,
|
||||||
|
shadowOpacity: NotchSettings.Defaults.shadowOpacity,
|
||||||
|
cornerRadiusScaling: NotchSettings.Defaults.cornerRadiusScaling,
|
||||||
|
notchOpacity: NotchSettings.Defaults.notchOpacity,
|
||||||
|
blurRadius: NotchSettings.Defaults.blurRadius
|
||||||
|
),
|
||||||
|
animation: AnimationSettings(
|
||||||
|
openSpringResponse: NotchSettings.Defaults.openSpringResponse,
|
||||||
|
openSpringDamping: NotchSettings.Defaults.openSpringDamping,
|
||||||
|
closeSpringResponse: NotchSettings.Defaults.closeSpringResponse,
|
||||||
|
closeSpringDamping: NotchSettings.Defaults.closeSpringDamping,
|
||||||
|
hoverSpringResponse: NotchSettings.Defaults.hoverSpringResponse,
|
||||||
|
hoverSpringDamping: NotchSettings.Defaults.hoverSpringDamping,
|
||||||
|
resizeAnimationDuration: NotchSettings.Defaults.resizeAnimationDuration
|
||||||
|
),
|
||||||
|
terminal: TerminalSettings(
|
||||||
|
fontSize: NotchSettings.Defaults.terminalFontSize,
|
||||||
|
shellPath: NotchSettings.Defaults.terminalShell,
|
||||||
|
themeRawValue: NotchSettings.Defaults.terminalTheme,
|
||||||
|
sizePresetsJSON: NotchSettings.Defaults.terminalSizePresets
|
||||||
|
),
|
||||||
|
hotkeys: HotkeySettings(
|
||||||
|
toggle: .cmdReturn,
|
||||||
|
newTab: .cmdT,
|
||||||
|
closeTab: .cmdW,
|
||||||
|
nextTab: .cmdShiftRB,
|
||||||
|
previousTab: .cmdShiftLB,
|
||||||
|
nextWorkspace: .cmdShiftDown,
|
||||||
|
previousWorkspace: .cmdShiftUp,
|
||||||
|
detachTab: .cmdD
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppSettings {
|
||||||
|
struct DisplaySettings: Equatable, Codable {
|
||||||
|
var showOnAllDisplays: Bool
|
||||||
|
var showMenuBarIcon: Bool
|
||||||
|
var launchAtLogin: Bool
|
||||||
|
var notchHeightMode: Int
|
||||||
|
var notchHeight: Double
|
||||||
|
var nonNotchHeightMode: Int
|
||||||
|
var nonNotchHeight: Double
|
||||||
|
var openWidth: Double
|
||||||
|
var openHeight: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BehaviorSettings: Equatable, Codable {
|
||||||
|
var openNotchOnHover: Bool
|
||||||
|
var minimumHoverDuration: Double
|
||||||
|
var enableGestures: Bool
|
||||||
|
var gestureSensitivity: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppearanceSettings: Equatable, Codable {
|
||||||
|
var enableShadow: Bool
|
||||||
|
var shadowRadius: Double
|
||||||
|
var shadowOpacity: Double
|
||||||
|
var cornerRadiusScaling: Bool
|
||||||
|
var notchOpacity: Double
|
||||||
|
var blurRadius: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnimationSettings: Equatable, Codable {
|
||||||
|
var openSpringResponse: Double
|
||||||
|
var openSpringDamping: Double
|
||||||
|
var closeSpringResponse: Double
|
||||||
|
var closeSpringDamping: Double
|
||||||
|
var hoverSpringResponse: Double
|
||||||
|
var hoverSpringDamping: Double
|
||||||
|
var resizeAnimationDuration: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalSettings: Equatable, Codable {
|
||||||
|
var fontSize: Double
|
||||||
|
var shellPath: String
|
||||||
|
var themeRawValue: String
|
||||||
|
var sizePresetsJSON: String
|
||||||
|
|
||||||
|
var theme: TerminalTheme {
|
||||||
|
TerminalTheme.resolve(themeRawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sizePresets: [TerminalSizePreset] {
|
||||||
|
TerminalSizePresetStore.decodePresets(from: sizePresetsJSON) ?? TerminalSizePresetStore.loadDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HotkeySettings: Equatable, Codable {
|
||||||
|
var toggle: HotkeyBinding
|
||||||
|
var newTab: HotkeyBinding
|
||||||
|
var closeTab: HotkeyBinding
|
||||||
|
var nextTab: HotkeyBinding
|
||||||
|
var previousTab: HotkeyBinding
|
||||||
|
var nextWorkspace: HotkeyBinding
|
||||||
|
var previousWorkspace: HotkeyBinding
|
||||||
|
var detachTab: HotkeyBinding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppSettings.DisplaySettings {
|
||||||
|
struct LayoutSignature: Equatable {
|
||||||
|
var notchHeightMode: Int
|
||||||
|
var notchHeight: Double
|
||||||
|
var nonNotchHeightMode: Int
|
||||||
|
var nonNotchHeight: Double
|
||||||
|
var openWidth: Double
|
||||||
|
var openHeight: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
var layoutSignature: LayoutSignature {
|
||||||
|
LayoutSignature(
|
||||||
|
notchHeightMode: notchHeightMode,
|
||||||
|
notchHeight: notchHeight,
|
||||||
|
nonNotchHeightMode: nonNotchHeightMode,
|
||||||
|
nonNotchHeight: nonNotchHeight,
|
||||||
|
openWidth: openWidth,
|
||||||
|
openHeight: openHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalSessionConfiguration: Equatable {
|
||||||
|
var fontSize: CGFloat
|
||||||
|
var theme: TerminalTheme
|
||||||
|
var shellPath: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol TerminalSessionConfigurationProviding: AnyObject {
|
||||||
|
var terminalSessionConfiguration: TerminalSessionConfiguration { get }
|
||||||
|
var hotkeySettings: AppSettings.HotkeySettings { get }
|
||||||
|
var terminalSizePresets: [TerminalSizePreset] { get }
|
||||||
|
}
|
||||||
74
CommandNotch/CommandNotch/Models/AppSettingsController.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppSettingsController: ObservableObject, TerminalSessionConfigurationProviding {
|
||||||
|
static let shared = AppSettingsController(
|
||||||
|
store: UserDefaultsAppSettingsStore(),
|
||||||
|
observeExternalChanges: true
|
||||||
|
)
|
||||||
|
|
||||||
|
@Published private(set) var settings: AppSettings
|
||||||
|
|
||||||
|
private let store: any AppSettingsStoreType
|
||||||
|
private let notificationCenter: NotificationCenter
|
||||||
|
private var defaultsObserver: NSObjectProtocol?
|
||||||
|
|
||||||
|
init(
|
||||||
|
store: any AppSettingsStoreType,
|
||||||
|
observeExternalChanges: Bool = false,
|
||||||
|
notificationCenter: NotificationCenter = .default
|
||||||
|
) {
|
||||||
|
self.store = store
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
|
self.settings = store.load()
|
||||||
|
|
||||||
|
if observeExternalChanges {
|
||||||
|
defaultsObserver = notificationCenter.addObserver(
|
||||||
|
forName: UserDefaults.didChangeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let defaultsObserver {
|
||||||
|
notificationCenter.removeObserver(defaultsObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var terminalSessionConfiguration: TerminalSessionConfiguration {
|
||||||
|
TerminalSessionConfiguration(
|
||||||
|
fontSize: CGFloat(settings.terminal.fontSize),
|
||||||
|
theme: settings.terminal.theme,
|
||||||
|
shellPath: settings.terminal.shellPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hotkeySettings: AppSettings.HotkeySettings {
|
||||||
|
settings.hotkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
var terminalSizePresets: [TerminalSizePreset] {
|
||||||
|
settings.terminal.sizePresets
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
let loaded = store.load()
|
||||||
|
guard loaded != settings else { return }
|
||||||
|
settings = loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(_ mutate: (inout AppSettings) -> Void) {
|
||||||
|
var updated = settings
|
||||||
|
mutate(&updated)
|
||||||
|
guard updated != settings else { return }
|
||||||
|
settings = updated
|
||||||
|
store.save(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
CommandNotch/CommandNotch/Models/AppSettingsStore.swift
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol AppSettingsStoreType {
|
||||||
|
func load() -> AppSettings
|
||||||
|
func save(_ settings: AppSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() -> AppSettings {
|
||||||
|
AppSettings(
|
||||||
|
display: .init(
|
||||||
|
showOnAllDisplays: bool(NotchSettings.Keys.showOnAllDisplays, default: NotchSettings.Defaults.showOnAllDisplays),
|
||||||
|
showMenuBarIcon: bool(NotchSettings.Keys.showMenuBarIcon, default: NotchSettings.Defaults.showMenuBarIcon),
|
||||||
|
launchAtLogin: bool(NotchSettings.Keys.launchAtLogin, default: NotchSettings.Defaults.launchAtLogin),
|
||||||
|
notchHeightMode: integer(NotchSettings.Keys.notchHeightMode, default: NotchSettings.Defaults.notchHeightMode),
|
||||||
|
notchHeight: double(NotchSettings.Keys.notchHeight, default: NotchSettings.Defaults.notchHeight),
|
||||||
|
nonNotchHeightMode: integer(NotchSettings.Keys.nonNotchHeightMode, default: NotchSettings.Defaults.nonNotchHeightMode),
|
||||||
|
nonNotchHeight: double(NotchSettings.Keys.nonNotchHeight, default: NotchSettings.Defaults.nonNotchHeight),
|
||||||
|
openWidth: double(NotchSettings.Keys.openWidth, default: NotchSettings.Defaults.openWidth),
|
||||||
|
openHeight: double(NotchSettings.Keys.openHeight, default: NotchSettings.Defaults.openHeight)
|
||||||
|
),
|
||||||
|
behavior: .init(
|
||||||
|
openNotchOnHover: bool(NotchSettings.Keys.openNotchOnHover, default: NotchSettings.Defaults.openNotchOnHover),
|
||||||
|
minimumHoverDuration: double(NotchSettings.Keys.minimumHoverDuration, default: NotchSettings.Defaults.minimumHoverDuration),
|
||||||
|
enableGestures: bool(NotchSettings.Keys.enableGestures, default: NotchSettings.Defaults.enableGestures),
|
||||||
|
gestureSensitivity: double(NotchSettings.Keys.gestureSensitivity, default: NotchSettings.Defaults.gestureSensitivity)
|
||||||
|
),
|
||||||
|
appearance: .init(
|
||||||
|
enableShadow: bool(NotchSettings.Keys.enableShadow, default: NotchSettings.Defaults.enableShadow),
|
||||||
|
shadowRadius: double(NotchSettings.Keys.shadowRadius, default: NotchSettings.Defaults.shadowRadius),
|
||||||
|
shadowOpacity: double(NotchSettings.Keys.shadowOpacity, default: NotchSettings.Defaults.shadowOpacity),
|
||||||
|
cornerRadiusScaling: bool(NotchSettings.Keys.cornerRadiusScaling, default: NotchSettings.Defaults.cornerRadiusScaling),
|
||||||
|
notchOpacity: double(NotchSettings.Keys.notchOpacity, default: NotchSettings.Defaults.notchOpacity),
|
||||||
|
blurRadius: double(NotchSettings.Keys.blurRadius, default: NotchSettings.Defaults.blurRadius)
|
||||||
|
),
|
||||||
|
animation: .init(
|
||||||
|
openSpringResponse: double(NotchSettings.Keys.openSpringResponse, default: NotchSettings.Defaults.openSpringResponse),
|
||||||
|
openSpringDamping: double(NotchSettings.Keys.openSpringDamping, default: NotchSettings.Defaults.openSpringDamping),
|
||||||
|
closeSpringResponse: double(NotchSettings.Keys.closeSpringResponse, default: NotchSettings.Defaults.closeSpringResponse),
|
||||||
|
closeSpringDamping: double(NotchSettings.Keys.closeSpringDamping, default: NotchSettings.Defaults.closeSpringDamping),
|
||||||
|
hoverSpringResponse: double(NotchSettings.Keys.hoverSpringResponse, default: NotchSettings.Defaults.hoverSpringResponse),
|
||||||
|
hoverSpringDamping: double(NotchSettings.Keys.hoverSpringDamping, default: NotchSettings.Defaults.hoverSpringDamping),
|
||||||
|
resizeAnimationDuration: double(NotchSettings.Keys.resizeAnimationDuration, default: NotchSettings.Defaults.resizeAnimationDuration)
|
||||||
|
),
|
||||||
|
terminal: .init(
|
||||||
|
fontSize: double(NotchSettings.Keys.terminalFontSize, default: NotchSettings.Defaults.terminalFontSize),
|
||||||
|
shellPath: string(NotchSettings.Keys.terminalShell, default: NotchSettings.Defaults.terminalShell),
|
||||||
|
themeRawValue: string(NotchSettings.Keys.terminalTheme, default: NotchSettings.Defaults.terminalTheme),
|
||||||
|
sizePresetsJSON: string(NotchSettings.Keys.terminalSizePresets, default: NotchSettings.Defaults.terminalSizePresets)
|
||||||
|
),
|
||||||
|
hotkeys: .init(
|
||||||
|
toggle: hotkey(NotchSettings.Keys.hotkeyToggle, default: .cmdReturn),
|
||||||
|
newTab: hotkey(NotchSettings.Keys.hotkeyNewTab, default: .cmdT),
|
||||||
|
closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW),
|
||||||
|
nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB),
|
||||||
|
previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB),
|
||||||
|
nextWorkspace: hotkey(NotchSettings.Keys.hotkeyNextWorkspace, default: .cmdShiftDown),
|
||||||
|
previousWorkspace: hotkey(NotchSettings.Keys.hotkeyPreviousWorkspace, default: .cmdShiftUp),
|
||||||
|
detachTab: hotkey(NotchSettings.Keys.hotkeyDetachTab, default: .cmdD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ settings: AppSettings) {
|
||||||
|
defaults.set(settings.display.showOnAllDisplays, forKey: NotchSettings.Keys.showOnAllDisplays)
|
||||||
|
defaults.set(settings.display.showMenuBarIcon, forKey: NotchSettings.Keys.showMenuBarIcon)
|
||||||
|
defaults.set(settings.display.launchAtLogin, forKey: NotchSettings.Keys.launchAtLogin)
|
||||||
|
defaults.set(settings.display.notchHeightMode, forKey: NotchSettings.Keys.notchHeightMode)
|
||||||
|
defaults.set(settings.display.notchHeight, forKey: NotchSettings.Keys.notchHeight)
|
||||||
|
defaults.set(settings.display.nonNotchHeightMode, forKey: NotchSettings.Keys.nonNotchHeightMode)
|
||||||
|
defaults.set(settings.display.nonNotchHeight, forKey: NotchSettings.Keys.nonNotchHeight)
|
||||||
|
defaults.set(settings.display.openWidth, forKey: NotchSettings.Keys.openWidth)
|
||||||
|
defaults.set(settings.display.openHeight, forKey: NotchSettings.Keys.openHeight)
|
||||||
|
|
||||||
|
defaults.set(settings.behavior.openNotchOnHover, forKey: NotchSettings.Keys.openNotchOnHover)
|
||||||
|
defaults.set(settings.behavior.minimumHoverDuration, forKey: NotchSettings.Keys.minimumHoverDuration)
|
||||||
|
defaults.set(settings.behavior.enableGestures, forKey: NotchSettings.Keys.enableGestures)
|
||||||
|
defaults.set(settings.behavior.gestureSensitivity, forKey: NotchSettings.Keys.gestureSensitivity)
|
||||||
|
|
||||||
|
defaults.set(settings.appearance.enableShadow, forKey: NotchSettings.Keys.enableShadow)
|
||||||
|
defaults.set(settings.appearance.shadowRadius, forKey: NotchSettings.Keys.shadowRadius)
|
||||||
|
defaults.set(settings.appearance.shadowOpacity, forKey: NotchSettings.Keys.shadowOpacity)
|
||||||
|
defaults.set(settings.appearance.cornerRadiusScaling, forKey: NotchSettings.Keys.cornerRadiusScaling)
|
||||||
|
defaults.set(settings.appearance.notchOpacity, forKey: NotchSettings.Keys.notchOpacity)
|
||||||
|
defaults.set(settings.appearance.blurRadius, forKey: NotchSettings.Keys.blurRadius)
|
||||||
|
|
||||||
|
defaults.set(settings.animation.openSpringResponse, forKey: NotchSettings.Keys.openSpringResponse)
|
||||||
|
defaults.set(settings.animation.openSpringDamping, forKey: NotchSettings.Keys.openSpringDamping)
|
||||||
|
defaults.set(settings.animation.closeSpringResponse, forKey: NotchSettings.Keys.closeSpringResponse)
|
||||||
|
defaults.set(settings.animation.closeSpringDamping, forKey: NotchSettings.Keys.closeSpringDamping)
|
||||||
|
defaults.set(settings.animation.hoverSpringResponse, forKey: NotchSettings.Keys.hoverSpringResponse)
|
||||||
|
defaults.set(settings.animation.hoverSpringDamping, forKey: NotchSettings.Keys.hoverSpringDamping)
|
||||||
|
defaults.set(settings.animation.resizeAnimationDuration, forKey: NotchSettings.Keys.resizeAnimationDuration)
|
||||||
|
|
||||||
|
defaults.set(settings.terminal.fontSize, forKey: NotchSettings.Keys.terminalFontSize)
|
||||||
|
defaults.set(settings.terminal.shellPath, forKey: NotchSettings.Keys.terminalShell)
|
||||||
|
defaults.set(settings.terminal.themeRawValue, forKey: NotchSettings.Keys.terminalTheme)
|
||||||
|
defaults.set(settings.terminal.sizePresetsJSON, forKey: NotchSettings.Keys.terminalSizePresets)
|
||||||
|
|
||||||
|
defaults.set(settings.hotkeys.toggle.toJSON(), forKey: NotchSettings.Keys.hotkeyToggle)
|
||||||
|
defaults.set(settings.hotkeys.newTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNewTab)
|
||||||
|
defaults.set(settings.hotkeys.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab)
|
||||||
|
defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab)
|
||||||
|
defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab)
|
||||||
|
defaults.set(settings.hotkeys.nextWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyNextWorkspace)
|
||||||
|
defaults.set(settings.hotkeys.previousWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousWorkspace)
|
||||||
|
defaults.set(settings.hotkeys.detachTab.toJSON(), forKey: NotchSettings.Keys.hotkeyDetachTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bool(_ key: String, default defaultValue: Bool) -> Bool {
|
||||||
|
guard defaults.object(forKey: key) != nil else { return defaultValue }
|
||||||
|
return defaults.bool(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func double(_ key: String, default defaultValue: Double) -> Double {
|
||||||
|
guard defaults.object(forKey: key) != nil else { return defaultValue }
|
||||||
|
return defaults.double(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func integer(_ key: String, default defaultValue: Int) -> Int {
|
||||||
|
guard defaults.object(forKey: key) != nil else { return defaultValue }
|
||||||
|
return defaults.integer(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func string(_ key: String, default defaultValue: String) -> String {
|
||||||
|
defaults.string(forKey: key) ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hotkey(_ key: String, default defaultValue: HotkeyBinding) -> HotkeyBinding {
|
||||||
|
guard let json = defaults.string(forKey: key),
|
||||||
|
let binding = HotkeyBinding.fromJSON(json) else { return defaultValue }
|
||||||
|
return binding
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import Carbon.HIToolbox
|
|||||||
|
|
||||||
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
|
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
|
||||||
/// Stored in UserDefaults as a JSON string.
|
/// Stored in UserDefaults as a JSON string.
|
||||||
struct HotkeyBinding: Codable, Equatable {
|
struct HotkeyBinding: Codable, Equatable, Hashable {
|
||||||
var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
|
var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
|
||||||
var keyCode: UInt16
|
var keyCode: UInt16
|
||||||
|
|
||||||
@@ -88,5 +88,28 @@ struct HotkeyBinding: Codable, Equatable {
|
|||||||
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
|
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
|
||||||
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
|
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
|
||||||
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
|
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
|
||||||
|
static let cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125)
|
||||||
|
static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126)
|
||||||
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
|
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
|
||||||
|
|
||||||
|
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {
|
||||||
|
guard let keyCode = keyCode(forDigit: digit) else { return nil }
|
||||||
|
return HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func keyCode(forDigit digit: Int) -> UInt16? {
|
||||||
|
switch digit {
|
||||||
|
case 0: return 29
|
||||||
|
case 1: return 18
|
||||||
|
case 2: return 19
|
||||||
|
case 3: return 20
|
||||||
|
case 4: return 21
|
||||||
|
case 5: return 23
|
||||||
|
case 6: return 22
|
||||||
|
case 7: return 26
|
||||||
|
case 8: return 28
|
||||||
|
case 9: return 25
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
189
CommandNotch/CommandNotch/Models/NotchOrchestrator.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol ScreenRegistryType: AnyObject {
|
||||||
|
func allScreens() -> [ScreenContext]
|
||||||
|
func screenContext(for id: ScreenID) -> ScreenContext?
|
||||||
|
func activeScreenID() -> ScreenID?
|
||||||
|
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID?
|
||||||
|
@discardableResult
|
||||||
|
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID?
|
||||||
|
func releaseWorkspacePresentation(for screenID: ScreenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol NotchPresentationHost: AnyObject {
|
||||||
|
func canPresentNotch(for screenID: ScreenID) -> Bool
|
||||||
|
func performOpenPresentation(for screenID: ScreenID)
|
||||||
|
func performClosePresentation(for screenID: ScreenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol SchedulerType {
|
||||||
|
@MainActor
|
||||||
|
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TaskScheduler: SchedulerType {
|
||||||
|
@MainActor
|
||||||
|
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable {
|
||||||
|
let task = Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnyCancellable {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotchOrchestrator {
|
||||||
|
private let screenRegistry: any ScreenRegistryType
|
||||||
|
private weak var host: (any NotchPresentationHost)?
|
||||||
|
private let settingsController: AppSettingsController
|
||||||
|
private let scheduler: any SchedulerType
|
||||||
|
|
||||||
|
private var hoverOpenTasks: [ScreenID: AnyCancellable] = [:]
|
||||||
|
private var closeTransitionTasks: [ScreenID: AnyCancellable] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
screenRegistry: any ScreenRegistryType,
|
||||||
|
host: any NotchPresentationHost,
|
||||||
|
settingsController: AppSettingsController? = nil,
|
||||||
|
scheduler: (any SchedulerType)? = nil
|
||||||
|
) {
|
||||||
|
self.screenRegistry = screenRegistry
|
||||||
|
self.host = host
|
||||||
|
self.settingsController = settingsController ?? AppSettingsController.shared
|
||||||
|
self.scheduler = scheduler ?? TaskScheduler()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleOnActiveScreen() {
|
||||||
|
guard let screenID = screenRegistry.activeScreenID(),
|
||||||
|
host?.canPresentNotch(for: screenID) == true,
|
||||||
|
let context = screenRegistry.screenContext(for: screenID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.notchState == .open {
|
||||||
|
close(screenID: screenID)
|
||||||
|
} else {
|
||||||
|
open(screenID: screenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(screenID: ScreenID) {
|
||||||
|
guard host?.canPresentNotch(for: screenID) == true,
|
||||||
|
let context = screenRegistry.screenContext(for: screenID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let presentingScreenID = screenRegistry.presentingScreenID(for: context.workspaceID),
|
||||||
|
presentingScreenID != screenID {
|
||||||
|
close(screenID: presentingScreenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelHoverOpen(for: screenID)
|
||||||
|
cancelCloseTransition(for: screenID)
|
||||||
|
context.cancelCloseTransition()
|
||||||
|
|
||||||
|
withAnimation(context.openAnimation) {
|
||||||
|
context.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = screenRegistry.claimWorkspacePresentation(for: screenID)
|
||||||
|
host?.performOpenPresentation(for: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func close(screenID: ScreenID) {
|
||||||
|
guard let context = screenRegistry.screenContext(for: screenID) else { return }
|
||||||
|
|
||||||
|
cancelHoverOpen(for: screenID)
|
||||||
|
cancelCloseTransition(for: screenID)
|
||||||
|
context.beginCloseTransition()
|
||||||
|
|
||||||
|
closeTransitionTasks[screenID] = scheduler.schedule(after: context.closeInteractionLockDuration) { [weak self] in
|
||||||
|
self?.finishCloseTransition(for: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(context.closeAnimation) {
|
||||||
|
context.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
screenRegistry.releaseWorkspacePresentation(for: screenID)
|
||||||
|
host?.performClosePresentation(for: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHoverChange(_ hovering: Bool, for screenID: ScreenID) {
|
||||||
|
guard let context = screenRegistry.screenContext(for: screenID) else { return }
|
||||||
|
|
||||||
|
context.isHovering = hovering
|
||||||
|
|
||||||
|
if hovering {
|
||||||
|
scheduleHoverOpenIfNeeded(for: screenID)
|
||||||
|
} else {
|
||||||
|
cancelHoverOpen(for: screenID)
|
||||||
|
context.clearHoverOpenSuppression()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAllPendingWork() {
|
||||||
|
for task in hoverOpenTasks.values {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
for task in closeTransitionTasks.values {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverOpenTasks.removeAll()
|
||||||
|
closeTransitionTasks.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleHoverOpenIfNeeded(for screenID: ScreenID) {
|
||||||
|
cancelHoverOpen(for: screenID)
|
||||||
|
|
||||||
|
guard let context = screenRegistry.screenContext(for: screenID) else { return }
|
||||||
|
guard settingsController.settings.behavior.openNotchOnHover,
|
||||||
|
context.notchState == .closed,
|
||||||
|
!context.isCloseTransitionActive,
|
||||||
|
!context.suppressHoverOpenUntilHoverExit,
|
||||||
|
context.isHovering else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverOpenTasks[screenID] = scheduler.schedule(after: settingsController.settings.behavior.minimumHoverDuration) { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
let context = self.screenRegistry.screenContext(for: screenID),
|
||||||
|
context.isHovering,
|
||||||
|
context.notchState == .closed,
|
||||||
|
!context.isCloseTransitionActive,
|
||||||
|
!context.suppressHoverOpenUntilHoverExit else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.hoverOpenTasks[screenID] = nil
|
||||||
|
self.open(screenID: screenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishCloseTransition(for screenID: ScreenID) {
|
||||||
|
closeTransitionTasks[screenID] = nil
|
||||||
|
guard let context = screenRegistry.screenContext(for: screenID) else { return }
|
||||||
|
|
||||||
|
context.endCloseTransition()
|
||||||
|
scheduleHoverOpenIfNeeded(for: screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelHoverOpen(for screenID: ScreenID) {
|
||||||
|
hoverOpenTasks[screenID]?.cancel()
|
||||||
|
hoverOpenTasks[screenID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelCloseTransition(for screenID: ScreenID) {
|
||||||
|
closeTransitionTasks[screenID]?.cancel()
|
||||||
|
closeTransitionTasks[screenID] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
/// Central registry of all user-configurable notch settings.
|
/// Central registry of all user-configurable notch settings.
|
||||||
enum NotchSettings {
|
enum NotchSettings {
|
||||||
@@ -36,6 +37,7 @@ enum NotchSettings {
|
|||||||
static let closeSpringDamping = "closeSpringDamping"
|
static let closeSpringDamping = "closeSpringDamping"
|
||||||
static let hoverSpringResponse = "hoverSpringResponse"
|
static let hoverSpringResponse = "hoverSpringResponse"
|
||||||
static let hoverSpringDamping = "hoverSpringDamping"
|
static let hoverSpringDamping = "hoverSpringDamping"
|
||||||
|
static let resizeAnimationDuration = "resizeAnimationDuration"
|
||||||
|
|
||||||
// Behavior
|
// Behavior
|
||||||
static let enableGestures = "enableGestures"
|
static let enableGestures = "enableGestures"
|
||||||
@@ -44,6 +46,10 @@ enum NotchSettings {
|
|||||||
// Terminal
|
// Terminal
|
||||||
static let terminalFontSize = "terminalFontSize"
|
static let terminalFontSize = "terminalFontSize"
|
||||||
static let terminalShell = "terminalShell"
|
static let terminalShell = "terminalShell"
|
||||||
|
static let terminalTheme = "terminalTheme"
|
||||||
|
static let terminalSizePresets = "terminalSizePresets"
|
||||||
|
static let workspaceSummaries = "workspaceSummaries"
|
||||||
|
static let screenAssignments = "screenAssignments"
|
||||||
|
|
||||||
// Hotkeys — each stores a HotkeyBinding JSON string
|
// Hotkeys — each stores a HotkeyBinding JSON string
|
||||||
static let hotkeyToggle = "hotkey_toggle"
|
static let hotkeyToggle = "hotkey_toggle"
|
||||||
@@ -51,6 +57,8 @@ enum NotchSettings {
|
|||||||
static let hotkeyCloseTab = "hotkey_closeTab"
|
static let hotkeyCloseTab = "hotkey_closeTab"
|
||||||
static let hotkeyNextTab = "hotkey_nextTab"
|
static let hotkeyNextTab = "hotkey_nextTab"
|
||||||
static let hotkeyPreviousTab = "hotkey_previousTab"
|
static let hotkeyPreviousTab = "hotkey_previousTab"
|
||||||
|
static let hotkeyNextWorkspace = "hotkey_nextWorkspace"
|
||||||
|
static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace"
|
||||||
static let hotkeyDetachTab = "hotkey_detachTab"
|
static let hotkeyDetachTab = "hotkey_detachTab"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +90,15 @@ enum NotchSettings {
|
|||||||
static let closeSpringDamping: Double = 1.0
|
static let closeSpringDamping: Double = 1.0
|
||||||
static let hoverSpringResponse: Double = 0.38
|
static let hoverSpringResponse: Double = 0.38
|
||||||
static let hoverSpringDamping: Double = 0.8
|
static let hoverSpringDamping: Double = 0.8
|
||||||
|
static let resizeAnimationDuration: Double = 0.42
|
||||||
|
|
||||||
static let enableGestures: Bool = true
|
static let enableGestures: Bool = true
|
||||||
static let gestureSensitivity: Double = 0.5
|
static let gestureSensitivity: Double = 0.5
|
||||||
|
|
||||||
static let terminalFontSize: Double = 13
|
static let terminalFontSize: Double = 13
|
||||||
static let terminalShell: String = ""
|
static let terminalShell: String = ""
|
||||||
|
static let terminalTheme: String = TerminalTheme.terminalApp.rawValue
|
||||||
|
static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON()
|
||||||
|
|
||||||
// Default hotkey bindings as JSON
|
// Default hotkey bindings as JSON
|
||||||
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
|
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
|
||||||
@@ -95,6 +106,8 @@ enum NotchSettings {
|
|||||||
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
|
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
|
||||||
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
|
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
|
||||||
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
|
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
|
||||||
|
static let hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON()
|
||||||
|
static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.toJSON()
|
||||||
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
|
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,18 +140,23 @@ enum NotchSettings {
|
|||||||
Keys.closeSpringDamping: Defaults.closeSpringDamping,
|
Keys.closeSpringDamping: Defaults.closeSpringDamping,
|
||||||
Keys.hoverSpringResponse: Defaults.hoverSpringResponse,
|
Keys.hoverSpringResponse: Defaults.hoverSpringResponse,
|
||||||
Keys.hoverSpringDamping: Defaults.hoverSpringDamping,
|
Keys.hoverSpringDamping: Defaults.hoverSpringDamping,
|
||||||
|
Keys.resizeAnimationDuration: Defaults.resizeAnimationDuration,
|
||||||
|
|
||||||
Keys.enableGestures: Defaults.enableGestures,
|
Keys.enableGestures: Defaults.enableGestures,
|
||||||
Keys.gestureSensitivity: Defaults.gestureSensitivity,
|
Keys.gestureSensitivity: Defaults.gestureSensitivity,
|
||||||
|
|
||||||
Keys.terminalFontSize: Defaults.terminalFontSize,
|
Keys.terminalFontSize: Defaults.terminalFontSize,
|
||||||
Keys.terminalShell: Defaults.terminalShell,
|
Keys.terminalShell: Defaults.terminalShell,
|
||||||
|
Keys.terminalTheme: Defaults.terminalTheme,
|
||||||
|
Keys.terminalSizePresets: Defaults.terminalSizePresets,
|
||||||
|
|
||||||
Keys.hotkeyToggle: Defaults.hotkeyToggle,
|
Keys.hotkeyToggle: Defaults.hotkeyToggle,
|
||||||
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
|
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
|
||||||
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
|
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
|
||||||
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
|
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
|
||||||
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
|
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
|
||||||
|
Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace,
|
||||||
|
Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace,
|
||||||
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
|
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -171,3 +189,88 @@ enum NonNotchHeightMode: Int, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TerminalSizePreset: Codable, Equatable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var width: Double
|
||||||
|
var height: Double
|
||||||
|
var hotkey: HotkeyBinding?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
width: Double,
|
||||||
|
height: Double,
|
||||||
|
hotkey: HotkeyBinding? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.hotkey = hotkey
|
||||||
|
}
|
||||||
|
|
||||||
|
var size: CGSize {
|
||||||
|
CGSize(width: width, height: height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TerminalSizePresetStore {
|
||||||
|
static func load() -> [TerminalSizePreset] {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets),
|
||||||
|
let presets = decodePresets(from: json) else {
|
||||||
|
return defaultPresets()
|
||||||
|
}
|
||||||
|
return presets
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ presets: [TerminalSizePreset]) {
|
||||||
|
UserDefaults.standard.set(encodePresets(presets), forKey: NotchSettings.Keys.terminalSizePresets)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
save(defaultPresets())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadDefaults() -> [TerminalSizePreset] {
|
||||||
|
defaultPresets()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultPresetsJSON() -> String {
|
||||||
|
encodePresets(defaultPresets())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? {
|
||||||
|
let used = Set(presets.compactMap(\.hotkey))
|
||||||
|
for digit in 1...9 {
|
||||||
|
guard let candidate = HotkeyBinding.cmdShiftDigit(digit) else { continue }
|
||||||
|
if !used.contains(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultPresets() -> [TerminalSizePreset] {
|
||||||
|
[
|
||||||
|
TerminalSizePreset(name: "Compact", width: 480, height: 220, hotkey: HotkeyBinding.cmdShiftDigit(1)),
|
||||||
|
TerminalSizePreset(name: "Default", width: 640, height: 350, hotkey: HotkeyBinding.cmdShiftDigit(2)),
|
||||||
|
TerminalSizePreset(name: "Large", width: 900, height: 500, hotkey: HotkeyBinding.cmdShiftDigit(3)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodePresets(from json: String) -> [TerminalSizePreset]? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode([TerminalSizePreset].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encodePresets(_ presets: [TerminalSizePreset]) -> String {
|
||||||
|
guard let data = try? JSONEncoder().encode(presets),
|
||||||
|
let json = String(data: data, encoding: .utf8) else {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
}
|
||||||
222
CommandNotch/CommandNotch/Models/ScreenContext.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
typealias ScreenID = String
|
||||||
|
|
||||||
|
/// Observable screen-local UI state for one physical display.
|
||||||
|
@MainActor
|
||||||
|
final class ScreenContext: ObservableObject, Identifiable {
|
||||||
|
private static let minimumOpenWidth: CGFloat = 320
|
||||||
|
private static let minimumOpenHeight: CGFloat = 140
|
||||||
|
private static let windowHorizontalPadding: CGFloat = 40
|
||||||
|
private static let windowVerticalPadding: CGFloat = 20
|
||||||
|
|
||||||
|
let id: ScreenID
|
||||||
|
|
||||||
|
@Published var workspaceID: WorkspaceID
|
||||||
|
@Published var notchState: NotchState = .closed
|
||||||
|
@Published var notchSize: CGSize
|
||||||
|
@Published var closedNotchSize: CGSize
|
||||||
|
@Published var isHovering = false
|
||||||
|
@Published var isCloseTransitionActive = false
|
||||||
|
@Published var suppressHoverOpenUntilHoverExit = false
|
||||||
|
@Published var isUserResizing = false
|
||||||
|
@Published var isPresetResizing = false
|
||||||
|
@Published private(set) var suppressCloseOnFocusLoss = false
|
||||||
|
|
||||||
|
var requestOpen: (() -> Void)?
|
||||||
|
var requestClose: (() -> Void)?
|
||||||
|
var requestWindowResize: (() -> Void)?
|
||||||
|
var requestTerminalFocus: (() -> Void)?
|
||||||
|
|
||||||
|
private let settingsController: AppSettingsController
|
||||||
|
private let screenProvider: @MainActor (ScreenID) -> NSScreen?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: ScreenID,
|
||||||
|
workspaceID: WorkspaceID,
|
||||||
|
settingsController: AppSettingsController? = nil,
|
||||||
|
screenProvider: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
|
||||||
|
NSScreen.screens.first { $0.displayUUID == screenID }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.workspaceID = workspaceID
|
||||||
|
self.settingsController = settingsController ?? AppSettingsController.shared
|
||||||
|
self.screenProvider = screenProvider
|
||||||
|
|
||||||
|
let closed = Self.resolveClosedNotchSize(
|
||||||
|
for: id,
|
||||||
|
using: self.settingsController.settings.display,
|
||||||
|
screenProvider: screenProvider
|
||||||
|
)
|
||||||
|
self.closedNotchSize = closed
|
||||||
|
self.notchSize = closed
|
||||||
|
}
|
||||||
|
|
||||||
|
var openAnimation: Animation {
|
||||||
|
let animation = settingsController.settings.animation
|
||||||
|
return .spring(
|
||||||
|
response: animation.openSpringResponse,
|
||||||
|
dampingFraction: animation.openSpringDamping
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var closeAnimation: Animation {
|
||||||
|
let animation = settingsController.settings.animation
|
||||||
|
return .spring(
|
||||||
|
response: animation.closeSpringResponse,
|
||||||
|
dampingFraction: animation.closeSpringDamping
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var openAnimationDuration: TimeInterval {
|
||||||
|
max(0.05, settingsController.settings.animation.resizeAnimationDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func open() {
|
||||||
|
notchSize = openNotchSize
|
||||||
|
notchState = .open
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
refreshClosedSize()
|
||||||
|
notchSize = closedNotchSize
|
||||||
|
notchState = .closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWorkspace(id: WorkspaceID) {
|
||||||
|
guard workspaceID != id else { return }
|
||||||
|
workspaceID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshClosedSize() {
|
||||||
|
closedNotchSize = Self.resolveClosedNotchSize(
|
||||||
|
for: id,
|
||||||
|
using: settingsController.settings.display,
|
||||||
|
screenProvider: screenProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var openNotchSize: CGSize {
|
||||||
|
let display = settingsController.settings.display
|
||||||
|
return clampedOpenSize(
|
||||||
|
CGSize(width: display.openWidth, height: display.openHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginInteractiveResize() {
|
||||||
|
isUserResizing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeOpenNotch(to proposedSize: CGSize) {
|
||||||
|
let clampedSize = clampedOpenSize(proposedSize)
|
||||||
|
if notchState == .open {
|
||||||
|
notchSize = clampedSize
|
||||||
|
}
|
||||||
|
requestWindowResize?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endInteractiveResize() {
|
||||||
|
if notchState == .open {
|
||||||
|
settingsController.update {
|
||||||
|
$0.display.openWidth = notchSize.width
|
||||||
|
$0.display.openHeight = notchSize.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isUserResizing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) {
|
||||||
|
setOpenSize(preset.size, notifyWindowResize: notifyWindowResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize {
|
||||||
|
let clampedSize = clampedOpenSize(proposedSize)
|
||||||
|
settingsController.update {
|
||||||
|
$0.display.openWidth = clampedSize.width
|
||||||
|
$0.display.openHeight = clampedSize.height
|
||||||
|
}
|
||||||
|
return clampedSize
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize {
|
||||||
|
let clampedSize = setStoredOpenSize(proposedSize)
|
||||||
|
if notchState == .open {
|
||||||
|
notchSize = clampedSize
|
||||||
|
}
|
||||||
|
if notifyWindowResize {
|
||||||
|
requestWindowResize?()
|
||||||
|
}
|
||||||
|
return clampedSize
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clampedOpenSize(_ size: CGSize) -> CGSize {
|
||||||
|
CGSize(
|
||||||
|
width: size.width.clamped(to: Self.minimumOpenWidth...maximumAllowedWidth),
|
||||||
|
height: size.height.clamped(to: Self.minimumOpenHeight...maximumAllowedHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var maximumAllowedWidth: CGFloat {
|
||||||
|
guard let screen = resolvedScreen() ?? NSScreen.main else {
|
||||||
|
return Self.minimumOpenWidth
|
||||||
|
}
|
||||||
|
return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var maximumAllowedHeight: CGFloat {
|
||||||
|
guard let screen = resolvedScreen() ?? NSScreen.main else {
|
||||||
|
return Self.minimumOpenHeight
|
||||||
|
}
|
||||||
|
return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
var closeInteractionLockDuration: TimeInterval {
|
||||||
|
max(settingsController.settings.animation.closeSpringResponse + 0.2, 0.35)
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginCloseTransition() {
|
||||||
|
isCloseTransitionActive = true
|
||||||
|
if isHovering {
|
||||||
|
suppressHoverOpenUntilHoverExit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelCloseTransition() {
|
||||||
|
isCloseTransitionActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func endCloseTransition() {
|
||||||
|
isCloseTransitionActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHoverOpenSuppression() {
|
||||||
|
suppressHoverOpenUntilHoverExit = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCloseOnFocusLossSuppressed(_ suppressed: Bool) {
|
||||||
|
suppressCloseOnFocusLoss = suppressed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedScreen() -> NSScreen? {
|
||||||
|
screenProvider(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveClosedNotchSize(
|
||||||
|
for screenID: ScreenID,
|
||||||
|
using settings: AppSettings.DisplaySettings,
|
||||||
|
screenProvider: @escaping @MainActor (ScreenID) -> NSScreen?
|
||||||
|
) -> CGSize {
|
||||||
|
let screen = screenProvider(screenID) ?? NSScreen.main
|
||||||
|
return screen?.closedNotchSize(using: settings) ?? CGSize(width: 220, height: 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CGFloat {
|
||||||
|
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
|
||||||
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
268
CommandNotch/CommandNotch/Models/ScreenRegistry.swift
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConnectedScreenSummary: Identifiable, Equatable {
|
||||||
|
let id: ScreenID
|
||||||
|
let displayName: String
|
||||||
|
let isActive: Bool
|
||||||
|
let assignedWorkspaceID: WorkspaceID
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ScreenRegistry: ObservableObject {
|
||||||
|
static let shared = ScreenRegistry(assignmentStore: UserDefaultsScreenAssignmentStore())
|
||||||
|
|
||||||
|
@Published private(set) var screenContexts: [ScreenContext] = []
|
||||||
|
|
||||||
|
private let workspaceRegistry: WorkspaceRegistry
|
||||||
|
private let settingsController: AppSettingsController
|
||||||
|
private let assignmentStore: any ScreenAssignmentStoreType
|
||||||
|
private let connectedScreenIDsProvider: @MainActor () -> [ScreenID]
|
||||||
|
private let activeScreenIDProvider: @MainActor () -> ScreenID?
|
||||||
|
private let screenLookup: @MainActor (ScreenID) -> NSScreen?
|
||||||
|
|
||||||
|
private var contextsByID: [ScreenID: ScreenContext] = [:]
|
||||||
|
private var preferredAssignments: [ScreenID: WorkspaceID]
|
||||||
|
private var workspacePresenters: [WorkspaceID: ScreenID] = [:]
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
workspaceRegistry: WorkspaceRegistry? = nil,
|
||||||
|
settingsController: AppSettingsController? = nil,
|
||||||
|
assignmentStore: (any ScreenAssignmentStoreType)? = nil,
|
||||||
|
initialAssignments: [ScreenID: WorkspaceID]? = nil,
|
||||||
|
connectedScreenIDsProvider: @escaping @MainActor () -> [ScreenID] = {
|
||||||
|
NSScreen.screens.map(\.displayUUID)
|
||||||
|
},
|
||||||
|
activeScreenIDProvider: @escaping @MainActor () -> ScreenID? = {
|
||||||
|
let mouseLocation = NSEvent.mouseLocation
|
||||||
|
return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }?.displayUUID
|
||||||
|
?? NSScreen.main?.displayUUID
|
||||||
|
},
|
||||||
|
screenLookup: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
|
||||||
|
NSScreen.screens.first { $0.displayUUID == screenID }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
let resolvedWorkspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
|
||||||
|
let resolvedSettingsController = settingsController ?? AppSettingsController.shared
|
||||||
|
let resolvedAssignmentStore = assignmentStore ?? UserDefaultsScreenAssignmentStore()
|
||||||
|
|
||||||
|
self.workspaceRegistry = resolvedWorkspaceRegistry
|
||||||
|
self.settingsController = resolvedSettingsController
|
||||||
|
self.assignmentStore = resolvedAssignmentStore
|
||||||
|
self.preferredAssignments = initialAssignments ?? resolvedAssignmentStore.loadScreenAssignments()
|
||||||
|
self.connectedScreenIDsProvider = connectedScreenIDsProvider
|
||||||
|
self.activeScreenIDProvider = activeScreenIDProvider
|
||||||
|
self.screenLookup = screenLookup
|
||||||
|
|
||||||
|
observeWorkspaceChanges()
|
||||||
|
refreshConnectedScreens()
|
||||||
|
}
|
||||||
|
|
||||||
|
func allScreens() -> [ScreenContext] {
|
||||||
|
screenContexts
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenContext(for id: ScreenID) -> ScreenContext? {
|
||||||
|
contextsByID[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceController(for screenID: ScreenID) -> WorkspaceController {
|
||||||
|
let workspaceID = contextsByID[screenID]?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
|
||||||
|
return workspaceRegistry.controller(for: workspaceID) ?? workspaceRegistry.defaultWorkspaceController
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignedScreenIDs(to workspaceID: WorkspaceID) -> [ScreenID] {
|
||||||
|
preferredAssignments
|
||||||
|
.filter { $0.value == workspaceID }
|
||||||
|
.map(\.key)
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignedScreenCount(to workspaceID: WorkspaceID) -> Int {
|
||||||
|
assignedScreenIDs(to: workspaceID).count
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectedScreenSummaries() -> [ConnectedScreenSummary] {
|
||||||
|
let activeScreenID = activeScreenID()
|
||||||
|
|
||||||
|
return screenContexts.enumerated().map { index, context in
|
||||||
|
ConnectedScreenSummary(
|
||||||
|
id: context.id,
|
||||||
|
displayName: resolvedDisplayName(for: context.id, fallbackIndex: index),
|
||||||
|
isActive: context.id == activeScreenID,
|
||||||
|
assignedWorkspaceID: context.workspaceID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID) {
|
||||||
|
guard workspaceRegistry.controller(for: workspaceID) != nil else { return }
|
||||||
|
|
||||||
|
let previousWorkspaceID = contextsByID[screenID]?.workspaceID ?? preferredAssignments[screenID]
|
||||||
|
preferredAssignments[screenID] = workspaceID
|
||||||
|
contextsByID[screenID]?.updateWorkspace(id: workspaceID)
|
||||||
|
|
||||||
|
if let previousWorkspaceID,
|
||||||
|
previousWorkspaceID != workspaceID,
|
||||||
|
workspacePresenters[previousWorkspaceID] == screenID {
|
||||||
|
workspacePresenters.removeValue(forKey: previousWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAssignments()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func assignActiveScreen(to workspaceID: WorkspaceID) -> ScreenID? {
|
||||||
|
guard let screenID = activeScreenID() else { return nil }
|
||||||
|
assignWorkspace(workspaceID, to: screenID)
|
||||||
|
return screenID
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? {
|
||||||
|
guard let screenID = workspacePresenters[workspaceID] else { return nil }
|
||||||
|
guard preferredAssignments[screenID] == workspaceID else {
|
||||||
|
workspacePresenters.removeValue(forKey: workspaceID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return screenID
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? {
|
||||||
|
guard let workspaceID = contextsByID[screenID]?.workspaceID ?? preferredAssignments[screenID] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousPresenter = workspacePresenters[workspaceID]
|
||||||
|
workspacePresenters[workspaceID] = screenID
|
||||||
|
return previousPresenter == screenID ? nil : previousPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseWorkspacePresentation(for screenID: ScreenID) {
|
||||||
|
workspacePresenters = workspacePresenters.filter { $0.value != screenID }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func deleteWorkspace(
|
||||||
|
_ workspaceID: WorkspaceID,
|
||||||
|
preferredFallback preferredFallbackID: WorkspaceID? = nil
|
||||||
|
) -> WorkspaceID? {
|
||||||
|
guard workspaceRegistry.canDeleteWorkspace(id: workspaceID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let fallbackWorkspaceID = workspaceRegistry.deletionFallbackWorkspaceID(
|
||||||
|
forDeleting: workspaceID,
|
||||||
|
preferredFallback: preferredFallbackID
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspacePresenters.removeValue(forKey: workspaceID)
|
||||||
|
|
||||||
|
for (screenID, assignedWorkspaceID) in preferredAssignments where assignedWorkspaceID == workspaceID {
|
||||||
|
preferredAssignments[screenID] = fallbackWorkspaceID
|
||||||
|
}
|
||||||
|
|
||||||
|
for context in contextsByID.values where context.workspaceID == workspaceID {
|
||||||
|
context.updateWorkspace(id: fallbackWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard workspaceRegistry.deleteWorkspace(id: workspaceID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAssignments()
|
||||||
|
return fallbackWorkspaceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeScreenID() -> ScreenID? {
|
||||||
|
activeScreenIDProvider() ?? screenContexts.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshConnectedScreens() {
|
||||||
|
let connectedScreenIDs = connectedScreenIDsProvider()
|
||||||
|
let validWorkspaceIDs = Set(workspaceRegistry.allWorkspaceSummaries().map(\.id))
|
||||||
|
let defaultWorkspaceID = workspaceRegistry.defaultWorkspaceID
|
||||||
|
var nextContextsByID: [ScreenID: ScreenContext] = [:]
|
||||||
|
var nextContexts: [ScreenContext] = []
|
||||||
|
|
||||||
|
for screenID in connectedScreenIDs {
|
||||||
|
let workspaceID = resolvedWorkspaceID(
|
||||||
|
for: screenID,
|
||||||
|
validWorkspaceIDs: validWorkspaceIDs,
|
||||||
|
defaultWorkspaceID: defaultWorkspaceID
|
||||||
|
)
|
||||||
|
|
||||||
|
let context = contextsByID[screenID] ?? ScreenContext(
|
||||||
|
id: screenID,
|
||||||
|
workspaceID: workspaceID,
|
||||||
|
settingsController: settingsController,
|
||||||
|
screenProvider: screenLookup
|
||||||
|
)
|
||||||
|
|
||||||
|
context.updateWorkspace(id: workspaceID)
|
||||||
|
context.refreshClosedSize()
|
||||||
|
|
||||||
|
nextContextsByID[screenID] = context
|
||||||
|
nextContexts.append(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
contextsByID = nextContextsByID
|
||||||
|
screenContexts = nextContexts
|
||||||
|
reconcileWorkspacePresenters()
|
||||||
|
persistAssignments()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedWorkspaceID(
|
||||||
|
for screenID: ScreenID,
|
||||||
|
validWorkspaceIDs: Set<WorkspaceID>,
|
||||||
|
defaultWorkspaceID: WorkspaceID
|
||||||
|
) -> WorkspaceID {
|
||||||
|
guard let preferredWorkspaceID = preferredAssignments[screenID],
|
||||||
|
validWorkspaceIDs.contains(preferredWorkspaceID) else {
|
||||||
|
preferredAssignments[screenID] = defaultWorkspaceID
|
||||||
|
return defaultWorkspaceID
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredWorkspaceID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeWorkspaceChanges() {
|
||||||
|
workspaceRegistry.$workspaceSummaries
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.refreshConnectedScreens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistAssignments() {
|
||||||
|
assignmentStore.saveScreenAssignments(preferredAssignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconcileWorkspacePresenters() {
|
||||||
|
let validScreenIDs = Set(contextsByID.keys)
|
||||||
|
let validAssignments = preferredAssignments
|
||||||
|
|
||||||
|
workspacePresenters = workspacePresenters.filter { workspaceID, screenID in
|
||||||
|
validScreenIDs.contains(screenID) && validAssignments[screenID] == workspaceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedDisplayName(for screenID: ScreenID, fallbackIndex: Int) -> String {
|
||||||
|
let fallbackName = "Screen \(fallbackIndex + 1)"
|
||||||
|
guard let screen = screenLookup(screenID) else {
|
||||||
|
return fallbackName
|
||||||
|
}
|
||||||
|
|
||||||
|
let localizedName = screen.localizedName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return localizedName.isEmpty ? fallbackName : localizedName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ScreenRegistry: ScreenRegistryType {}
|
||||||
75
CommandNotch/CommandNotch/Models/TerminalManager.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Compatibility adapter for the legacy single-workspace architecture.
|
||||||
|
/// New code should use `WorkspaceRegistry` + `WorkspaceController`.
|
||||||
|
@MainActor
|
||||||
|
class TerminalManager: ObservableObject {
|
||||||
|
|
||||||
|
static let shared = TerminalManager()
|
||||||
|
|
||||||
|
private var workspaceCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
workspaceCancellable = WorkspaceRegistry.shared.defaultWorkspaceController.objectWillChange
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var workspace: WorkspaceController {
|
||||||
|
WorkspaceRegistry.shared.defaultWorkspaceController
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabs: [TerminalSession] {
|
||||||
|
workspace.tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTabIndex: Int {
|
||||||
|
workspace.activeTabIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTab: TerminalSession? {
|
||||||
|
workspace.activeTab
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTitle: String {
|
||||||
|
workspace.activeTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTab() {
|
||||||
|
workspace.newTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeActiveTab() {
|
||||||
|
workspace.closeActiveTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTab(at index: Int) {
|
||||||
|
workspace.closeTab(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchToTab(at index: Int) {
|
||||||
|
workspace.switchToTab(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextTab() {
|
||||||
|
workspace.nextTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousTab() {
|
||||||
|
workspace.previousTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func detachActiveTab() -> TerminalSession? {
|
||||||
|
workspace.detachActiveTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllFontSizes(_ size: CGFloat) {
|
||||||
|
workspace.updateAllFontSizes(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllThemes(_ theme: TerminalTheme) {
|
||||||
|
workspace.updateAllThemes(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,32 +4,51 @@ import Combine
|
|||||||
|
|
||||||
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
|
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
|
||||||
@MainActor
|
@MainActor
|
||||||
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate {
|
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate {
|
||||||
|
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let terminalView: TerminalView
|
let terminalView: TerminalView
|
||||||
private var process: LocalProcess?
|
private var process: LocalProcess?
|
||||||
|
private var keyEventMonitor: Any?
|
||||||
|
private let backgroundColor = NSColor.black
|
||||||
|
private let configuredShellPath: String
|
||||||
|
private let launchDirectory: String
|
||||||
|
|
||||||
@Published var title: String = "shell"
|
@Published var title: String = "shell"
|
||||||
@Published var isRunning: Bool = true
|
@Published var isRunning: Bool = true
|
||||||
@Published var currentDirectory: String?
|
@Published var currentDirectory: String?
|
||||||
|
|
||||||
init(fontSize: CGFloat) {
|
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))
|
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||||
|
configuredShellPath = shellPath
|
||||||
|
launchDirectory = Self.resolveInitialDirectory(initialDirectory)
|
||||||
|
currentDirectory = launchDirectory
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
terminalView.terminalDelegate = self
|
terminalView.terminalDelegate = self
|
||||||
|
|
||||||
// Solid black — matches every other element in the notch.
|
|
||||||
// The single `.opacity(notchOpacity)` on ContentView makes
|
|
||||||
// everything uniformly transparent.
|
|
||||||
terminalView.nativeBackgroundColor = .black
|
|
||||||
terminalView.nativeForegroundColor = .init(white: 0.9, alpha: 1.0)
|
|
||||||
|
|
||||||
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||||
terminalView.font = font
|
terminalView.font = font
|
||||||
|
applyTheme(theme)
|
||||||
|
installCommandArrowMonitor()
|
||||||
|
|
||||||
|
if startImmediately {
|
||||||
startShell()
|
startShell()
|
||||||
|
} else {
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let keyEventMonitor {
|
||||||
|
NSEvent.removeMonitor(keyEventMonitor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shell management
|
// MARK: - Shell management
|
||||||
@@ -39,31 +58,76 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
|
|||||||
let shellName = (shellPath as NSString).lastPathComponent
|
let shellName = (shellPath as NSString).lastPathComponent
|
||||||
let loginExecName = "-\(shellName)"
|
let loginExecName = "-\(shellName)"
|
||||||
|
|
||||||
FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
|
|
||||||
let proc = LocalProcess(delegate: self)
|
let proc = LocalProcess(delegate: self)
|
||||||
// Launch as a login shell so user startup files initialize PATH/tools.
|
// Launch as a login shell so user startup files initialize PATH/tools.
|
||||||
proc.startProcess(
|
proc.startProcess(
|
||||||
executable: shellPath,
|
executable: shellPath,
|
||||||
args: ["-l"],
|
args: ["-l"],
|
||||||
environment: nil,
|
environment: nil,
|
||||||
execName: loginExecName
|
execName: loginExecName,
|
||||||
|
currentDirectory: launchDirectory
|
||||||
)
|
)
|
||||||
process = proc
|
process = proc
|
||||||
title = shellName
|
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 {
|
private func resolveShell() -> String {
|
||||||
let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? ""
|
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
|
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
|
||||||
return custom
|
return custom
|
||||||
}
|
}
|
||||||
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func installCommandArrowMonitor() {
|
||||||
|
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||||
|
guard let self else { return event }
|
||||||
|
guard let window = self.terminalView.window else { return event }
|
||||||
|
guard event.window === window else { return event }
|
||||||
|
guard window.firstResponder === self.terminalView else { return event }
|
||||||
|
|
||||||
|
guard let sequence = TerminalCommandArrowBehavior.sequence(
|
||||||
|
for: event.modifierFlags,
|
||||||
|
keyCode: event.keyCode,
|
||||||
|
applicationCursor: self.terminalView.getTerminal().applicationCursor
|
||||||
|
) else {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
self.terminalView.send(data: sequence[...])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updateFontSize(_ size: CGFloat) {
|
func updateFontSize(_ size: CGFloat) {
|
||||||
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyTheme(_ theme: TerminalTheme) {
|
||||||
|
// Keep the notch visually consistent while swapping the terminal's
|
||||||
|
// default foreground color and ANSI palette for command output.
|
||||||
|
terminalView.nativeBackgroundColor = backgroundColor
|
||||||
|
terminalView.nativeForegroundColor = theme.foregroundColor
|
||||||
|
terminalView.installColors(theme.ansiColors)
|
||||||
|
}
|
||||||
|
|
||||||
func terminate() {
|
func terminate() {
|
||||||
process?.terminate()
|
process?.terminate()
|
||||||
process = nil
|
process = nil
|
||||||
@@ -111,7 +175,8 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
||||||
currentDirectory = directory
|
guard let normalizedDirectory = Self.normalizedDirectory(directory) else { return }
|
||||||
|
currentDirectory = normalizedDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrolled(source: TerminalView, position: Double) {}
|
func scrolled(source: TerminalView, position: Double) {}
|
||||||
117
CommandNotch/CommandNotch/Models/TerminalTheme.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftTerm
|
||||||
|
|
||||||
|
enum TerminalTheme: String, CaseIterable, Identifiable {
|
||||||
|
case terminalApp
|
||||||
|
case xterm
|
||||||
|
case solarizedDark
|
||||||
|
case dracula
|
||||||
|
case nord
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .terminalApp: return "Classic"
|
||||||
|
case .xterm: return "Xterm"
|
||||||
|
case .solarizedDark:return "Solarized Dark"
|
||||||
|
case .dracula: return "Dracula"
|
||||||
|
case .nord: return "Nord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail: String {
|
||||||
|
switch self {
|
||||||
|
case .terminalApp:
|
||||||
|
return "Matches the app's current terminal palette."
|
||||||
|
case .xterm:
|
||||||
|
return "Traditional xterm-style ANSI colors."
|
||||||
|
case .solarizedDark:
|
||||||
|
return "Low-contrast dark palette with Solarized accents."
|
||||||
|
case .dracula:
|
||||||
|
return "Higher-contrast dark palette with vivid ANSI colors."
|
||||||
|
case .nord:
|
||||||
|
return "Cool blue-grey palette with restrained accents."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var foregroundColor: NSColor {
|
||||||
|
switch self {
|
||||||
|
case .terminalApp:
|
||||||
|
return Self.nsColor(0xE5E5E5)
|
||||||
|
case .xterm:
|
||||||
|
return Self.nsColor(0xE5E5E5)
|
||||||
|
case .solarizedDark:
|
||||||
|
return Self.nsColor(0x839496)
|
||||||
|
case .dracula:
|
||||||
|
return Self.nsColor(0xF8F8F2)
|
||||||
|
case .nord:
|
||||||
|
return Self.nsColor(0xD8DEE9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ansiColors: [Color] {
|
||||||
|
switch self {
|
||||||
|
case .terminalApp:
|
||||||
|
return Self.palette([
|
||||||
|
0x000000, 0xC23621, 0x25BC24, 0xADAD27,
|
||||||
|
0x492EE1, 0xD338D3, 0x33BBC8, 0xCBCCCD,
|
||||||
|
0x818383, 0xFC391F, 0x31E722, 0xEAEC23,
|
||||||
|
0x5833FF, 0xF935F8, 0x14F0F0, 0xE9EBEB
|
||||||
|
])
|
||||||
|
case .xterm:
|
||||||
|
return Self.palette([
|
||||||
|
0x000000, 0xCD0000, 0x00CD00, 0xCDCD00,
|
||||||
|
0x0000EE, 0xCD00CD, 0x00CDCD, 0xE5E5E5,
|
||||||
|
0x7F7F7F, 0xFF0000, 0x00FF00, 0xFFFF00,
|
||||||
|
0x5C5CFF, 0xFF00FF, 0x00FFFF, 0xFFFFFF
|
||||||
|
])
|
||||||
|
case .solarizedDark:
|
||||||
|
return Self.palette([
|
||||||
|
0x073642, 0xDC322F, 0x859900, 0xB58900,
|
||||||
|
0x268BD2, 0xD33682, 0x2AA198, 0xEEE8D5,
|
||||||
|
0x002B36, 0xCB4B16, 0x586E75, 0x657B83,
|
||||||
|
0x839496, 0x6C71C4, 0x93A1A1, 0xFDF6E3
|
||||||
|
])
|
||||||
|
case .dracula:
|
||||||
|
return Self.palette([
|
||||||
|
0x21222C, 0xFF5555, 0x50FA7B, 0xF1FA8C,
|
||||||
|
0xBD93F9, 0xFF79C6, 0x8BE9FD, 0xF8F8F2,
|
||||||
|
0x6272A4, 0xFF6E6E, 0x69FF94, 0xFFFFA5,
|
||||||
|
0xD6ACFF, 0xFF92DF, 0xA4FFFF, 0xFFFFFF
|
||||||
|
])
|
||||||
|
case .nord:
|
||||||
|
return Self.palette([
|
||||||
|
0x3B4252, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
|
||||||
|
0x81A1C1, 0xB48EAD, 0x88C0D0, 0xE5E9F0,
|
||||||
|
0x4C566A, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
|
||||||
|
0x81A1C1, 0xB48EAD, 0x8FBCBB, 0xECEFF4
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(_ rawValue: String) -> TerminalTheme {
|
||||||
|
TerminalTheme(rawValue: rawValue) ?? .terminalApp
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func palette(_ hexValues: [UInt32]) -> [Color] {
|
||||||
|
hexValues.map(terminalColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func terminalColor(_ hex: UInt32) -> Color {
|
||||||
|
Color(
|
||||||
|
red: UInt16(((hex >> 16) & 0xFF) * 257),
|
||||||
|
green: UInt16(((hex >> 8) & 0xFF) * 257),
|
||||||
|
blue: UInt16((hex & 0xFF) * 257)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nsColor(_ hex: UInt32) -> NSColor {
|
||||||
|
NSColor(
|
||||||
|
deviceRed: CGFloat((hex >> 16) & 0xFF) / 255.0,
|
||||||
|
green: CGFloat((hex >> 8) & 0xFF) / 255.0,
|
||||||
|
blue: CGFloat(hex & 0xFF) / 255.0,
|
||||||
|
alpha: 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
190
CommandNotch/CommandNotch/Models/WorkspaceController.swift
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol TerminalSessionFactoryType {
|
||||||
|
func makeSession(
|
||||||
|
fontSize: CGFloat,
|
||||||
|
theme: TerminalTheme,
|
||||||
|
shellPath: String,
|
||||||
|
initialDirectory: String?
|
||||||
|
) -> TerminalSession
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveTerminalSessionFactory: TerminalSessionFactoryType {
|
||||||
|
func makeSession(
|
||||||
|
fontSize: CGFloat,
|
||||||
|
theme: TerminalTheme,
|
||||||
|
shellPath: String,
|
||||||
|
initialDirectory: String?
|
||||||
|
) -> TerminalSession {
|
||||||
|
TerminalSession(
|
||||||
|
fontSize: fontSize,
|
||||||
|
theme: theme,
|
||||||
|
shellPath: shellPath,
|
||||||
|
initialDirectory: initialDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WorkspaceController: ObservableObject {
|
||||||
|
let id: WorkspaceID
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
@Published private(set) var name: String
|
||||||
|
@Published private(set) var hotkey: HotkeyBinding?
|
||||||
|
@Published private(set) var tabs: [TerminalSession] = []
|
||||||
|
@Published private(set) var activeTabIndex: Int = 0
|
||||||
|
|
||||||
|
private let sessionFactory: TerminalSessionFactoryType
|
||||||
|
private let settingsProvider: TerminalSessionConfigurationProviding
|
||||||
|
private var titleObservers: [UUID: AnyCancellable] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
summary: WorkspaceSummary,
|
||||||
|
sessionFactory: TerminalSessionFactoryType,
|
||||||
|
settingsProvider: TerminalSessionConfigurationProviding,
|
||||||
|
bootstrapDefaultTab: Bool = true
|
||||||
|
) {
|
||||||
|
self.id = summary.id
|
||||||
|
self.name = summary.name
|
||||||
|
self.createdAt = summary.createdAt
|
||||||
|
self.hotkey = summary.hotkey
|
||||||
|
self.sessionFactory = sessionFactory
|
||||||
|
self.settingsProvider = settingsProvider
|
||||||
|
|
||||||
|
if bootstrapDefaultTab {
|
||||||
|
newTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(summary: WorkspaceSummary) {
|
||||||
|
self.init(
|
||||||
|
summary: summary,
|
||||||
|
sessionFactory: LiveTerminalSessionFactory(),
|
||||||
|
settingsProvider: AppSettingsController.shared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: WorkspaceSummary {
|
||||||
|
WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: WorkspaceState {
|
||||||
|
WorkspaceState(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
tabs: tabs.map { WorkspaceTabState(id: $0.id, title: $0.title) },
|
||||||
|
activeTabID: activeTab?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTab: TerminalSession? {
|
||||||
|
guard tabs.indices.contains(activeTabIndex) else { return nil }
|
||||||
|
return tabs[activeTabIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTitle: String {
|
||||||
|
activeTab?.title ?? "shell"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rename(to updatedName: String) {
|
||||||
|
let trimmed = updatedName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, trimmed != name else { return }
|
||||||
|
name = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateHotkey(_ updatedHotkey: HotkeyBinding?) {
|
||||||
|
guard hotkey != updatedHotkey else { return }
|
||||||
|
hotkey = updatedHotkey
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTab() {
|
||||||
|
let config = settingsProvider.terminalSessionConfiguration
|
||||||
|
let session = sessionFactory.makeSession(
|
||||||
|
fontSize: config.fontSize,
|
||||||
|
theme: config.theme,
|
||||||
|
shellPath: config.shellPath,
|
||||||
|
initialDirectory: activeTab?.currentDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
titleObservers[session.id] = session.$title
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.append(session)
|
||||||
|
activeTabIndex = tabs.count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTab(at index: Int) {
|
||||||
|
guard tabs.indices.contains(index) else { return }
|
||||||
|
|
||||||
|
let session = tabs.remove(at: index)
|
||||||
|
titleObservers.removeValue(forKey: session.id)
|
||||||
|
session.terminate()
|
||||||
|
|
||||||
|
if tabs.isEmpty {
|
||||||
|
newTab()
|
||||||
|
} else if activeTabIndex >= tabs.count {
|
||||||
|
activeTabIndex = tabs.count - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeActiveTab() {
|
||||||
|
closeTab(at: activeTabIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchToTab(at index: Int) {
|
||||||
|
guard tabs.indices.contains(index) else { return }
|
||||||
|
activeTabIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchToTab(id: UUID) {
|
||||||
|
guard let index = tabs.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
activeTabIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextTab() {
|
||||||
|
guard tabs.count > 1 else { return }
|
||||||
|
activeTabIndex = (activeTabIndex + 1) % tabs.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousTab() {
|
||||||
|
guard tabs.count > 1 else { return }
|
||||||
|
activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func detachTab(at index: Int) -> TerminalSession? {
|
||||||
|
guard tabs.indices.contains(index) else { return nil }
|
||||||
|
|
||||||
|
let session = tabs.remove(at: index)
|
||||||
|
titleObservers.removeValue(forKey: session.id)
|
||||||
|
|
||||||
|
if tabs.isEmpty {
|
||||||
|
newTab()
|
||||||
|
} else if activeTabIndex >= tabs.count {
|
||||||
|
activeTabIndex = tabs.count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func detachActiveTab() -> TerminalSession? {
|
||||||
|
detachTab(at: activeTabIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllFontSizes(_ size: CGFloat) {
|
||||||
|
for tab in tabs {
|
||||||
|
tab.updateFontSize(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllThemes(_ theme: TerminalTheme) {
|
||||||
|
for tab in tabs {
|
||||||
|
tab.applyTheme(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WorkspaceRegistry: ObservableObject {
|
||||||
|
static let shared = WorkspaceRegistry(store: UserDefaultsWorkspaceStore())
|
||||||
|
|
||||||
|
@Published private(set) var workspaceSummaries: [WorkspaceSummary]
|
||||||
|
|
||||||
|
private let store: any WorkspaceStoreType
|
||||||
|
private var controllers: [WorkspaceID: WorkspaceController] = [:]
|
||||||
|
private let controllerFactory: @MainActor (WorkspaceSummary) -> WorkspaceController
|
||||||
|
|
||||||
|
init(
|
||||||
|
initialWorkspaces: [WorkspaceSummary]? = nil,
|
||||||
|
store: (any WorkspaceStoreType)? = nil,
|
||||||
|
controllerFactory: @escaping @MainActor (WorkspaceSummary) -> WorkspaceController = { summary in
|
||||||
|
WorkspaceController(summary: summary)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
let resolvedStore = store ?? UserDefaultsWorkspaceStore()
|
||||||
|
let resolvedWorkspaces = initialWorkspaces ?? resolvedStore.loadWorkspaceSummaries()
|
||||||
|
|
||||||
|
self.store = resolvedStore
|
||||||
|
self.controllerFactory = controllerFactory
|
||||||
|
self.workspaceSummaries = resolvedWorkspaces
|
||||||
|
|
||||||
|
for summary in resolvedWorkspaces {
|
||||||
|
controllers[summary.id] = controllerFactory(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ensureWorkspaceExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultWorkspaceID: WorkspaceID {
|
||||||
|
ensureWorkspaceExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultWorkspaceController: WorkspaceController {
|
||||||
|
let workspaceID = ensureWorkspaceExists()
|
||||||
|
guard let controller = controllers[workspaceID] else {
|
||||||
|
let summary = WorkspaceSummary(id: workspaceID, name: "Main")
|
||||||
|
let controller = controllerFactory(summary)
|
||||||
|
controllers[workspaceID] = controller
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func allWorkspaceSummaries() -> [WorkspaceSummary] {
|
||||||
|
workspaceSummaries
|
||||||
|
}
|
||||||
|
|
||||||
|
func summary(for id: WorkspaceID) -> WorkspaceSummary? {
|
||||||
|
workspaceSummaries.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func controller(for id: WorkspaceID) -> WorkspaceController? {
|
||||||
|
controllers[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func canDeleteWorkspace(id: WorkspaceID) -> Bool {
|
||||||
|
workspaceSummaries.count > 1 && workspaceSummaries.contains { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletionFallbackWorkspaceID(
|
||||||
|
forDeleting id: WorkspaceID,
|
||||||
|
preferredFallback preferredFallbackID: WorkspaceID? = nil
|
||||||
|
) -> WorkspaceID? {
|
||||||
|
let candidates = workspaceSummaries.filter { $0.id != id }
|
||||||
|
|
||||||
|
if let preferredFallbackID,
|
||||||
|
candidates.contains(where: { $0.id == preferredFallbackID }) {
|
||||||
|
return preferredFallbackID
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func ensureWorkspaceExists() -> WorkspaceID {
|
||||||
|
if let existing = workspaceSummaries.first {
|
||||||
|
return existing.id
|
||||||
|
}
|
||||||
|
return createWorkspace(named: "Main")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createWorkspace(named name: String? = nil) -> WorkspaceID {
|
||||||
|
let workspaceName = resolvedWorkspaceName(from: name)
|
||||||
|
let summary = WorkspaceSummary(name: workspaceName)
|
||||||
|
workspaceSummaries.append(summary)
|
||||||
|
controllers[summary.id] = controllerFactory(summary)
|
||||||
|
persistWorkspaceSummaries()
|
||||||
|
return summary.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameWorkspace(id: WorkspaceID, to name: String) {
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
|
||||||
|
workspaceSummaries[index].name = trimmed
|
||||||
|
controllers[id]?.rename(to: trimmed)
|
||||||
|
persistWorkspaceSummaries()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWorkspaceHotkey(id: WorkspaceID, to hotkey: HotkeyBinding?) {
|
||||||
|
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
guard workspaceSummaries[index].hotkey != hotkey else { return }
|
||||||
|
|
||||||
|
workspaceSummaries[index].hotkey = hotkey
|
||||||
|
controllers[id]?.updateHotkey(hotkey)
|
||||||
|
persistWorkspaceSummaries()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextWorkspaceID(after id: WorkspaceID) -> WorkspaceID? {
|
||||||
|
guard !workspaceSummaries.isEmpty else { return nil }
|
||||||
|
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
|
||||||
|
return workspaceSummaries.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = workspaceSummaries.index(after: index)
|
||||||
|
return workspaceSummaries[nextIndex == workspaceSummaries.endIndex ? workspaceSummaries.startIndex : nextIndex].id
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousWorkspaceID(before id: WorkspaceID) -> WorkspaceID? {
|
||||||
|
guard !workspaceSummaries.isEmpty else { return nil }
|
||||||
|
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
|
||||||
|
return workspaceSummaries.last?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousIndex = index == workspaceSummaries.startIndex
|
||||||
|
? workspaceSummaries.index(before: workspaceSummaries.endIndex)
|
||||||
|
: workspaceSummaries.index(before: index)
|
||||||
|
return workspaceSummaries[previousIndex].id
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func deleteWorkspace(id: WorkspaceID) -> Bool {
|
||||||
|
guard canDeleteWorkspace(id: id) else { return false }
|
||||||
|
workspaceSummaries.removeAll { $0.id == id }
|
||||||
|
controllers.removeValue(forKey: id)
|
||||||
|
_ = ensureWorkspaceExists()
|
||||||
|
persistWorkspaceSummaries()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllWorkspacesFontSizes(_ size: CGFloat) {
|
||||||
|
for controller in controllers.values {
|
||||||
|
controller.updateAllFontSizes(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllWorkspacesThemes(_ theme: TerminalTheme) {
|
||||||
|
for controller in controllers.values {
|
||||||
|
controller.updateAllThemes(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedWorkspaceName(from proposedName: String?) -> String {
|
||||||
|
let trimmed = proposedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = Set(workspaceSummaries.map(\.name))
|
||||||
|
if !existing.contains("Main") {
|
||||||
|
return "Main"
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = 2
|
||||||
|
while existing.contains("Workspace \(index)") {
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
return "Workspace \(index)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistWorkspaceSummaries() {
|
||||||
|
store.saveWorkspaceSummaries(workspaceSummaries)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
CommandNotch/CommandNotch/Models/WorkspaceStore.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol WorkspaceStoreType {
|
||||||
|
func loadWorkspaceSummaries() -> [WorkspaceSummary]
|
||||||
|
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary])
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol ScreenAssignmentStoreType {
|
||||||
|
func loadScreenAssignments() -> [ScreenID: WorkspaceID]
|
||||||
|
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID])
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserDefaultsWorkspaceStore: WorkspaceStoreType {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWorkspaceSummaries() -> [WorkspaceSummary] {
|
||||||
|
guard let data = defaults.data(forKey: NotchSettings.Keys.workspaceSummaries),
|
||||||
|
let summaries = try? decoder.decode([WorkspaceSummary].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary]) {
|
||||||
|
guard let data = try? encoder.encode(summaries) else { return }
|
||||||
|
defaults.set(data, forKey: NotchSettings.Keys.workspaceSummaries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserDefaultsScreenAssignmentStore: ScreenAssignmentStoreType {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadScreenAssignments() -> [ScreenID: WorkspaceID] {
|
||||||
|
guard let data = defaults.data(forKey: NotchSettings.Keys.screenAssignments),
|
||||||
|
let assignments = try? decoder.decode([ScreenID: WorkspaceID].self, from: data) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID]) {
|
||||||
|
guard let data = try? encoder.encode(assignments) else { return }
|
||||||
|
defaults.set(data, forKey: NotchSettings.Keys.screenAssignments)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
CommandNotch/CommandNotch/Models/WorkspaceSummary.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
typealias WorkspaceID = UUID
|
||||||
|
|
||||||
|
struct WorkspaceSummary: Identifiable, Equatable, Codable {
|
||||||
|
var id: WorkspaceID
|
||||||
|
var name: String
|
||||||
|
var createdAt: Date
|
||||||
|
var hotkey: HotkeyBinding?
|
||||||
|
|
||||||
|
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date(), hotkey: HotkeyBinding? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.hotkey = hotkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceTabState: Identifiable, Equatable {
|
||||||
|
var id: UUID
|
||||||
|
var title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceState: Equatable {
|
||||||
|
var id: WorkspaceID
|
||||||
|
var name: String
|
||||||
|
var tabs: [WorkspaceTabState]
|
||||||
|
var activeTabID: UUID?
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_16x16.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_16x16@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_32x32.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_32x32@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_128x128.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_128x128@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_256x256.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_256x256@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_512x512.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_512x512@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 945 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 735 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,19 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Downterm</string>
|
<string>CommandNotch</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>Downterm</string>
|
<string>CommandNotch</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>com.downterm.app</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Downterm</string>
|
<string>CommandNotch</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.2.0</string>
|
<string>0.0.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>Copyright © 2026 Downterm. All rights reserved.</string>
|
<string>Copyright © 2026 CommandNotch. All rights reserved.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
29
CommandNotch/CommandNotch/Views/AboutSettingsView.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AboutSettingsView: View {
|
||||||
|
private var versionLabel: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "terminal")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("CommandNotch")
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
|
||||||
|
Text("Version \(versionLabel)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("A drop-down terminal that lives in your notch.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
CommandNotch/CommandNotch/Views/AnimationSettingsView.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AnimationSettingsView: View {
|
||||||
|
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Open Animation") {
|
||||||
|
springControls(
|
||||||
|
response: settingsController.binding(\.animation.openSpringResponse),
|
||||||
|
damping: settingsController.binding(\.animation.openSpringDamping)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Close Animation") {
|
||||||
|
springControls(
|
||||||
|
response: settingsController.binding(\.animation.closeSpringResponse),
|
||||||
|
damping: settingsController.binding(\.animation.closeSpringDamping)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Hover Animation") {
|
||||||
|
springControls(
|
||||||
|
response: settingsController.binding(\.animation.hoverSpringResponse),
|
||||||
|
damping: settingsController.binding(\.animation.hoverSpringDamping)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Resize Animation") {
|
||||||
|
durationControl(duration: settingsController.binding(\.animation.resizeAnimationDuration))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Reset to Defaults") {
|
||||||
|
settingsController.update {
|
||||||
|
$0.animation = AppSettings.default.animation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text("Response")
|
||||||
|
Slider(value: response, in: 0.1...1.5, step: 0.01)
|
||||||
|
Text(String(format: "%.2f", response.wrappedValue))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Damping")
|
||||||
|
Slider(value: damping, in: 0.1...1.5, step: 0.01)
|
||||||
|
Text(String(format: "%.2f", damping.wrappedValue))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func durationControl(duration: Binding<Double>) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text("Duration")
|
||||||
|
Slider(value: duration, in: 0.05...1.5, step: 0.01)
|
||||||
|
Text(String(format: "%.2fs", duration.wrappedValue))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 56)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
CommandNotch/CommandNotch/Views/AppearanceSettingsView.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AppearanceSettingsView: View {
|
||||||
|
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Shadow") {
|
||||||
|
Toggle("Enable shadow", isOn: settingsController.binding(\.appearance.enableShadow))
|
||||||
|
if settingsController.settings.appearance.enableShadow {
|
||||||
|
HStack {
|
||||||
|
Text("Radius")
|
||||||
|
Slider(value: settingsController.binding(\.appearance.shadowRadius), in: 0...30, step: 1)
|
||||||
|
Text(String(format: "%.0f", settingsController.settings.appearance.shadowRadius))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 40)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Opacity")
|
||||||
|
Slider(value: settingsController.binding(\.appearance.shadowOpacity), in: 0...1, step: 0.05)
|
||||||
|
Text(String(format: "%.2f", settingsController.settings.appearance.shadowOpacity))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Shape") {
|
||||||
|
Toggle("Scale corner radii when open", isOn: settingsController.binding(\.appearance.cornerRadiusScaling))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Opacity & Blur") {
|
||||||
|
HStack {
|
||||||
|
Text("Notch opacity")
|
||||||
|
Slider(value: settingsController.binding(\.appearance.notchOpacity), in: 0...1, step: 0.05)
|
||||||
|
Text(String(format: "%.2f", settingsController.settings.appearance.notchOpacity))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Blur radius")
|
||||||
|
Slider(value: settingsController.binding(\.appearance.blurRadius), in: 0...20, step: 0.5)
|
||||||
|
Text(String(format: "%.1f", settingsController.settings.appearance.blurRadius))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
CommandNotch/CommandNotch/Views/GeneralSettingsView.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GeneralSettingsView: View {
|
||||||
|
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||||
|
|
||||||
|
private var maxOpenWidth: Double {
|
||||||
|
let currentWidth = settingsController.settings.display.openWidth
|
||||||
|
let screenWidth = NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600
|
||||||
|
return max(currentWidth, Double(screenWidth.rounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var maxOpenHeight: Double {
|
||||||
|
let currentHeight = settingsController.settings.display.openHeight
|
||||||
|
let screenHeight = NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900
|
||||||
|
return max(currentHeight, Double(screenHeight.rounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Display") {
|
||||||
|
Toggle("Show on all displays", isOn: settingsController.binding(\.display.showOnAllDisplays))
|
||||||
|
Toggle("Show menu bar icon", isOn: settingsController.binding(\.display.showMenuBarIcon))
|
||||||
|
Toggle("Launch at login", isOn: settingsController.binding(\.display.launchAtLogin))
|
||||||
|
.onChange(of: settingsController.settings.display.launchAtLogin) { _, newValue in
|
||||||
|
LaunchAtLoginHelper.setEnabled(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Hover Behavior") {
|
||||||
|
Toggle("Open notch on hover", isOn: settingsController.binding(\.behavior.openNotchOnHover))
|
||||||
|
if settingsController.settings.behavior.openNotchOnHover {
|
||||||
|
HStack {
|
||||||
|
Text("Hover delay")
|
||||||
|
Slider(value: settingsController.binding(\.behavior.minimumHoverDuration), in: 0.0...2.0, step: 0.05)
|
||||||
|
Text(String(format: "%.2fs", settingsController.settings.behavior.minimumHoverDuration))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Gestures") {
|
||||||
|
Toggle("Enable gestures", isOn: settingsController.binding(\.behavior.enableGestures))
|
||||||
|
if settingsController.settings.behavior.enableGestures {
|
||||||
|
HStack {
|
||||||
|
Text("Sensitivity")
|
||||||
|
Slider(value: settingsController.binding(\.behavior.gestureSensitivity), in: 0.1...1.0, step: 0.05)
|
||||||
|
Text(String(format: "%.2f", settingsController.settings.behavior.gestureSensitivity))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Closed Notch Size") {
|
||||||
|
Picker("Notch screens", selection: settingsController.binding(\.display.notchHeightMode)) {
|
||||||
|
ForEach(NotchHeightMode.allCases) { mode in
|
||||||
|
Text(mode.label).tag(mode.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settingsController.settings.display.notchHeightMode == NotchHeightMode.custom.rawValue {
|
||||||
|
HStack {
|
||||||
|
Text("Custom height")
|
||||||
|
Slider(value: settingsController.binding(\.display.notchHeight), in: 16...64, step: 1)
|
||||||
|
Text("\(Int(settingsController.settings.display.notchHeight))pt")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Non-notch screens", selection: settingsController.binding(\.display.nonNotchHeightMode)) {
|
||||||
|
ForEach(NonNotchHeightMode.allCases) { mode in
|
||||||
|
Text(mode.label).tag(mode.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settingsController.settings.display.nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
|
||||||
|
HStack {
|
||||||
|
Text("Custom height")
|
||||||
|
Slider(value: settingsController.binding(\.display.nonNotchHeight), in: 16...64, step: 1)
|
||||||
|
Text("\(Int(settingsController.settings.display.nonNotchHeight))pt")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Open Notch Size") {
|
||||||
|
HStack {
|
||||||
|
Text("Width")
|
||||||
|
Slider(value: settingsController.binding(\.display.openWidth), in: 320...maxOpenWidth, step: 10)
|
||||||
|
Text("\(Int(settingsController.settings.display.openWidth))pt")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Height")
|
||||||
|
Slider(value: settingsController.binding(\.display.openHeight), in: 140...maxOpenHeight, step: 10)
|
||||||
|
Text("\(Int(settingsController.settings.display.openHeight))pt")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
CommandNotch/CommandNotch/Views/HotkeySettingsView.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HotkeySettingsView: View {
|
||||||
|
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Global") {
|
||||||
|
HotkeyRecorderView(label: "Toggle notch", binding: settingsController.binding(\.hotkeys.toggle))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Terminal Tabs (active when notch is open)") {
|
||||||
|
HotkeyRecorderView(label: "New tab", binding: settingsController.binding(\.hotkeys.newTab))
|
||||||
|
HotkeyRecorderView(label: "Close tab", binding: settingsController.binding(\.hotkeys.closeTab))
|
||||||
|
HotkeyRecorderView(label: "Next tab", binding: settingsController.binding(\.hotkeys.nextTab))
|
||||||
|
HotkeyRecorderView(label: "Previous tab", binding: settingsController.binding(\.hotkeys.previousTab))
|
||||||
|
HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Workspaces (active when notch is open)") {
|
||||||
|
HotkeyRecorderView(label: "Next workspace", binding: settingsController.binding(\.hotkeys.nextWorkspace))
|
||||||
|
HotkeyRecorderView(label: "Previous workspace", binding: settingsController.binding(\.hotkeys.previousWorkspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets. Per-workspace jump hotkeys are configured in Workspaces.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Reset to Defaults") {
|
||||||
|
settingsController.update {
|
||||||
|
$0.hotkeys = AppSettings.default.hotkeys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
CommandNotch/CommandNotch/Views/SettingsBindings.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
extension AppSettingsController {
|
||||||
|
func binding<Value>(_ keyPath: WritableKeyPath<AppSettings, Value>) -> Binding<Value> {
|
||||||
|
Binding(
|
||||||
|
get: { self.settings[keyPath: keyPath] },
|
||||||
|
set: { newValue in
|
||||||
|
self.update { $0[keyPath: keyPath] = newValue }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
CommandNotch/CommandNotch/Views/SettingsView.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@State private var selectedTab: SettingsTab = .general
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
List(SettingsTab.allCases, selection: $selectedTab) { tab in
|
||||||
|
Label(tab.label, systemImage: tab.icon)
|
||||||
|
.tag(tab)
|
||||||
|
.accessibilityIdentifier("settings.tab.\(tab.rawValue)")
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
||||||
|
} detail: {
|
||||||
|
ScrollView {
|
||||||
|
detailView.padding()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var detailView: some View {
|
||||||
|
switch selectedTab {
|
||||||
|
case .general:
|
||||||
|
GeneralSettingsView()
|
||||||
|
case .appearance:
|
||||||
|
AppearanceSettingsView()
|
||||||
|
case .workspaces:
|
||||||
|
WorkspacesSettingsView()
|
||||||
|
case .animation:
|
||||||
|
AnimationSettingsView()
|
||||||
|
case .terminal:
|
||||||
|
TerminalSettingsView()
|
||||||
|
case .hotkeys:
|
||||||
|
HotkeySettingsView()
|
||||||
|
case .about:
|
||||||
|
AboutSettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
|
case general, appearance, workspaces, animation, terminal, hotkeys, about
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .general:
|
||||||
|
"General"
|
||||||
|
case .appearance:
|
||||||
|
"Appearance"
|
||||||
|
case .workspaces:
|
||||||
|
"Workspaces"
|
||||||
|
case .animation:
|
||||||
|
"Animation"
|
||||||
|
case .terminal:
|
||||||
|
"Terminal"
|
||||||
|
case .hotkeys:
|
||||||
|
"Hotkeys"
|
||||||
|
case .about:
|
||||||
|
"About"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .general:
|
||||||
|
"gearshape"
|
||||||
|
case .appearance:
|
||||||
|
"paintbrush"
|
||||||
|
case .workspaces:
|
||||||
|
"rectangle.3.group"
|
||||||
|
case .animation:
|
||||||
|
"bolt.fill"
|
||||||
|
case .terminal:
|
||||||
|
"terminal"
|
||||||
|
case .hotkeys:
|
||||||
|
"keyboard"
|
||||||
|
case .about:
|
||||||
|
"info.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
CommandNotch/CommandNotch/Views/TerminalSettingsView.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TerminalSettingsView: View {
|
||||||
|
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||||
|
@State private var sizePresets: [TerminalSizePreset] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Font") {
|
||||||
|
HStack {
|
||||||
|
Text("Font size")
|
||||||
|
Slider(value: settingsController.binding(\.terminal.fontSize), in: 8...28, step: 1)
|
||||||
|
Text("\(Int(settingsController.settings.terminal.fontSize))pt")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Colors") {
|
||||||
|
Picker("Theme", selection: settingsController.binding(\.terminal.themeRawValue)) {
|
||||||
|
ForEach(TerminalTheme.allCases) { terminalTheme in
|
||||||
|
Text(terminalTheme.label).tag(terminalTheme.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(settingsController.settings.terminal.theme.detail)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Shell") {
|
||||||
|
TextField("Shell path (empty = $SHELL)", text: settingsController.binding(\.terminal.shellPath))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Size Presets") {
|
||||||
|
ForEach($sizePresets) { $preset in
|
||||||
|
TerminalSizePresetEditor(
|
||||||
|
preset: $preset,
|
||||||
|
currentOpenWidth: settingsController.settings.display.openWidth,
|
||||||
|
currentOpenHeight: settingsController.settings.display.openHeight,
|
||||||
|
onDelete: { deletePreset(id: preset.id) },
|
||||||
|
onApply: { applyPreset(preset) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Add Preset") {
|
||||||
|
sizePresets.append(
|
||||||
|
TerminalSizePreset(
|
||||||
|
name: "Preset \(sizePresets.count + 1)",
|
||||||
|
width: settingsController.settings.display.openWidth,
|
||||||
|
height: settingsController.settings.display.openHeight,
|
||||||
|
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Reset Presets") {
|
||||||
|
sizePresets = TerminalSizePresetStore.loadDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.onAppear {
|
||||||
|
synchronizePresetsFromSettings()
|
||||||
|
}
|
||||||
|
.onChange(of: settingsController.settings.terminal.sizePresetsJSON) { _, _ in
|
||||||
|
synchronizePresetsFromSettings()
|
||||||
|
}
|
||||||
|
.onChange(of: sizePresets) { _, newValue in
|
||||||
|
let encoded = TerminalSizePresetStore.encodePresets(newValue)
|
||||||
|
guard encoded != settingsController.settings.terminal.sizePresetsJSON else { return }
|
||||||
|
|
||||||
|
settingsController.update {
|
||||||
|
$0.terminal.sizePresetsJSON = encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deletePreset(id: UUID) {
|
||||||
|
sizePresets.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPreset(_ preset: TerminalSizePreset) {
|
||||||
|
settingsController.update {
|
||||||
|
$0.display.openWidth = preset.width
|
||||||
|
$0.display.openHeight = preset.height
|
||||||
|
}
|
||||||
|
ScreenManager.shared.applySizePreset(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func synchronizePresetsFromSettings() {
|
||||||
|
let decoded = TerminalSizePresetStore.decodePresets(
|
||||||
|
from: settingsController.settings.terminal.sizePresetsJSON
|
||||||
|
) ?? TerminalSizePresetStore.loadDefaults()
|
||||||
|
|
||||||
|
guard decoded != sizePresets else { return }
|
||||||
|
sizePresets = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TerminalSizePresetEditor: View {
|
||||||
|
@Binding var preset: TerminalSizePreset
|
||||||
|
|
||||||
|
let currentOpenWidth: Double
|
||||||
|
let currentOpenHeight: Double
|
||||||
|
let onDelete: () -> Void
|
||||||
|
let onApply: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
TextField("Preset name", text: $preset.name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button(role: .destructive, action: onDelete) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Width")
|
||||||
|
TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0)))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 90)
|
||||||
|
|
||||||
|
Text("Height")
|
||||||
|
TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0)))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 90)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Use Current Size") {
|
||||||
|
preset.width = currentOpenWidth
|
||||||
|
preset.height = currentOpenHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Apply", action: onApply)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
CommandNotch/CommandNotch/Views/WorkspaceSwitcherView.swift
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WorkspaceSwitcherView: View {
|
||||||
|
@ObservedObject var screen: ScreenContext
|
||||||
|
let orchestrator: NotchOrchestrator
|
||||||
|
@ObservedObject private var screenRegistry = ScreenRegistry.shared
|
||||||
|
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
|
||||||
|
|
||||||
|
@State private var isRenameAlertPresented = false
|
||||||
|
@State private var isDeleteConfirmationPresented = false
|
||||||
|
@State private var renameDraft = ""
|
||||||
|
|
||||||
|
private var currentWorkspaceSummary: WorkspaceSummary {
|
||||||
|
workspaceRegistry.summary(for: screen.workspaceID)
|
||||||
|
?? workspaceRegistry.allWorkspaceSummaries().first
|
||||||
|
?? WorkspaceSummary(id: screen.workspaceID, name: "Workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deletionFallbackSummary: WorkspaceSummary? {
|
||||||
|
guard let fallbackWorkspaceID = workspaceRegistry.deletionFallbackWorkspaceID(
|
||||||
|
forDeleting: screen.workspaceID,
|
||||||
|
preferredFallback: workspaceRegistry.defaultWorkspaceID
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceRegistry.summary(for: fallbackWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var assignedScreenCount: Int {
|
||||||
|
screenRegistry.assignedScreenCount(to: screen.workspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(workspaceRegistry.workspaceSummaries) { summary in
|
||||||
|
Button {
|
||||||
|
selectWorkspace(summary.id)
|
||||||
|
} label: {
|
||||||
|
if summary.id == screen.workspaceID {
|
||||||
|
Label(summary.name, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(summary.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("New Workspace") {
|
||||||
|
let workspaceID = workspaceRegistry.createWorkspace()
|
||||||
|
selectWorkspace(workspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Rename Current Workspace") {
|
||||||
|
renameDraft = currentWorkspaceSummary.name
|
||||||
|
syncFocusLossSuppression(renamePresented: true, deletePresented: isDeleteConfirmationPresented)
|
||||||
|
isRenameAlertPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Delete Current Workspace", role: .destructive) {
|
||||||
|
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: true)
|
||||||
|
isDeleteConfirmationPresented = true
|
||||||
|
}
|
||||||
|
.disabled(!workspaceRegistry.canDeleteWorkspace(id: screen.workspaceID))
|
||||||
|
} label: {
|
||||||
|
switcherLabel
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.accessibilityIdentifier("notch.workspace-switcher")
|
||||||
|
.accessibilityLabel("Workspace Switcher")
|
||||||
|
.accessibilityValue(currentWorkspaceSummary.name)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.help("Switch workspace for this screen")
|
||||||
|
.alert("Rename Workspace", isPresented: $isRenameAlertPresented) {
|
||||||
|
TextField("Workspace name", text: $renameDraft)
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Save") {
|
||||||
|
workspaceRegistry.renameWorkspace(id: screen.workspaceID, to: renameDraft)
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("This only renames the shared workspace. Screens assigned to it keep following the new name.")
|
||||||
|
}
|
||||||
|
.confirmationDialog("Delete Workspace", isPresented: $isDeleteConfirmationPresented, titleVisibility: .visible) {
|
||||||
|
Button("Delete Workspace", role: .destructive) {
|
||||||
|
deleteCurrentWorkspace()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(deleteMessage)
|
||||||
|
}
|
||||||
|
.onChange(of: isRenameAlertPresented) { _, isPresented in
|
||||||
|
syncFocusLossSuppression(renamePresented: isPresented, deletePresented: isDeleteConfirmationPresented)
|
||||||
|
}
|
||||||
|
.onChange(of: isDeleteConfirmationPresented) { _, isPresented in
|
||||||
|
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: isPresented)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
screen.setCloseOnFocusLossSuppressed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteMessage: String {
|
||||||
|
if let fallback = deletionFallbackSummary {
|
||||||
|
return "This reassigns \(assignedScreenCount) screen\(assignedScreenCount == 1 ? "" : "s") to \(fallback.name) and closes this workspace."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "At least one workspace must remain."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var switcherLabel: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "rectangle.3.group")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
Text(currentWorkspaceSummary.name)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.white.opacity(0.08))
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("Workspace Switcher")
|
||||||
|
.accessibilityValue(currentWorkspaceSummary.name)
|
||||||
|
.accessibilityIdentifier("notch.workspace-switcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectWorkspace(_ workspaceID: WorkspaceID) {
|
||||||
|
screenRegistry.assignWorkspace(workspaceID, to: screen.id)
|
||||||
|
|
||||||
|
if screen.notchState == .open {
|
||||||
|
orchestrator.open(screenID: screen.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteCurrentWorkspace() {
|
||||||
|
guard let fallback = screenRegistry.deleteWorkspace(
|
||||||
|
screen.workspaceID,
|
||||||
|
preferredFallback: workspaceRegistry.defaultWorkspaceID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
screenRegistry.assignWorkspace(fallback, to: screen.id)
|
||||||
|
if screen.notchState == .open {
|
||||||
|
orchestrator.open(screenID: screen.id)
|
||||||
|
} else {
|
||||||
|
screen.requestTerminalFocus?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncFocusLossSuppression(renamePresented: Bool, deletePresented: Bool) {
|
||||||
|
screen.setCloseOnFocusLossSuppressed(renamePresented || deletePresented)
|
||||||
|
}
|
||||||
|
}
|
||||||
291
CommandNotch/CommandNotch/Views/WorkspacesSettingsView.swift
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WorkspacesSettingsView: View {
|
||||||
|
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
|
||||||
|
@ObservedObject private var screenRegistry = ScreenRegistry.shared
|
||||||
|
|
||||||
|
@State private var selectedWorkspaceID: WorkspaceID?
|
||||||
|
@State private var renameDraft = ""
|
||||||
|
@State private var isDeleteAlertPresented = false
|
||||||
|
|
||||||
|
private var effectiveSelectedWorkspaceID: WorkspaceID? {
|
||||||
|
selectedWorkspaceID ?? workspaceRegistry.workspaceSummaries.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedSummary: WorkspaceSummary? {
|
||||||
|
guard let effectiveSelectedWorkspaceID else { return nil }
|
||||||
|
return workspaceRegistry.summary(for: effectiveSelectedWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedController: WorkspaceController? {
|
||||||
|
guard let effectiveSelectedWorkspaceID else { return nil }
|
||||||
|
return workspaceRegistry.controller(for: effectiveSelectedWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedAssignedScreenIDs: [ScreenID] {
|
||||||
|
guard let effectiveSelectedWorkspaceID else { return [] }
|
||||||
|
return screenRegistry.assignedScreenIDs(to: effectiveSelectedWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectedScreenSummaries: [ConnectedScreenSummary] {
|
||||||
|
screenRegistry.connectedScreenSummaries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeConnectedScreenSummary: ConnectedScreenSummary? {
|
||||||
|
connectedScreenSummaries.first(where: \.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deletionFallbackSummary: WorkspaceSummary? {
|
||||||
|
guard let effectiveSelectedWorkspaceID,
|
||||||
|
let fallbackID = workspaceRegistry.deletionFallbackWorkspaceID(
|
||||||
|
forDeleting: effectiveSelectedWorkspaceID,
|
||||||
|
preferredFallback: workspaceRegistry.defaultWorkspaceID
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceRegistry.summary(for: fallbackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
List(selection: $selectedWorkspaceID) {
|
||||||
|
ForEach(workspaceRegistry.workspaceSummaries) { summary in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(summary.name)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(usageDescription(for: summary))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.tag(summary.id)
|
||||||
|
.accessibilityIdentifier("settings.workspace.row.\(summary.id.uuidString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings.workspaces.list")
|
||||||
|
.frame(minWidth: 220, idealWidth: 240, maxWidth: 260, maxHeight: .infinity)
|
||||||
|
|
||||||
|
if let summary = selectedSummary {
|
||||||
|
Form {
|
||||||
|
Section("Identity") {
|
||||||
|
TextField("Workspace name", text: $renameDraft)
|
||||||
|
.accessibilityIdentifier("settings.workspaces.name-field")
|
||||||
|
.onSubmit {
|
||||||
|
renameSelectedWorkspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionalHotkeyRecorderView(
|
||||||
|
label: "Jump Hotkey",
|
||||||
|
binding: workspaceHotkeyBinding(for: summary.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Save Name") {
|
||||||
|
renameSelectedWorkspace()
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings.workspaces.save-name")
|
||||||
|
|
||||||
|
Button("New Workspace") {
|
||||||
|
createWorkspace()
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings.workspaces.new")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Workspace jump hotkeys are active when the notch is open and switch the current screen to this workspace.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Usage") {
|
||||||
|
LabeledContent("Assigned screens") {
|
||||||
|
Text("\(selectedAssignedScreenIDs.count)")
|
||||||
|
.accessibilityIdentifier("settings.workspaces.assigned-count")
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent("Open tabs") {
|
||||||
|
Text("\(selectedController?.tabs.count ?? 0)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedAssignedScreenIDs.isEmpty {
|
||||||
|
Text("No screens are currently assigned to this workspace.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(selectedAssignedScreenIDs, id: \.self) { screenID in
|
||||||
|
LabeledContent("Screen") {
|
||||||
|
Text(screenID)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Shared Workspace Rules") {
|
||||||
|
Text(sharedWorkspaceDescription(for: selectedAssignedScreenIDs.count))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Connected Screens") {
|
||||||
|
if let activeScreen = activeConnectedScreenSummary {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(activeScreen.displayName)
|
||||||
|
Text(activeScreen.id)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(activeScreen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Current Screen") {
|
||||||
|
screenRegistry.assignWorkspace(summary.id, to: activeScreen.id)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings.workspaces.assign-current")
|
||||||
|
.disabled(activeScreen.assignedWorkspaceID == summary.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No connected screens are currently available.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(connectedScreenSummaries.filter { !$0.isActive }) { screen in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(screen.displayName)
|
||||||
|
Text(screen.id)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(screen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Here") {
|
||||||
|
screenRegistry.assignWorkspace(summary.id, to: screen.id)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings.workspaces.assign.\(screen.id)")
|
||||||
|
.disabled(screen.assignedWorkspaceID == summary.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Danger Zone") {
|
||||||
|
Button("Delete Workspace", role: .destructive) {
|
||||||
|
isDeleteAlertPresented = true
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings.workspaces.delete")
|
||||||
|
.disabled(!workspaceRegistry.canDeleteWorkspace(id: summary.id))
|
||||||
|
|
||||||
|
if !workspaceRegistry.canDeleteWorkspace(id: summary.id) {
|
||||||
|
Text("At least one workspace must remain.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Workspaces",
|
||||||
|
systemImage: "rectangle.3.group",
|
||||||
|
description: Text("Create a workspace to start grouping tabs across screens.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
selectInitialWorkspaceIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: workspaceRegistry.workspaceSummaries) { _, _ in
|
||||||
|
synchronizeSelectionWithRegistry()
|
||||||
|
}
|
||||||
|
.onChange(of: selectedWorkspaceID) { _, _ in
|
||||||
|
renameDraft = selectedSummary?.name ?? ""
|
||||||
|
}
|
||||||
|
.alert("Delete Workspace", isPresented: $isDeleteAlertPresented) {
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
deleteSelectedWorkspace()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let summary = selectedSummary, let fallback = deletionFallbackSummary {
|
||||||
|
Text(
|
||||||
|
"Deleting \(summary.name) reassigns its screens to \(fallback.name) and closes the workspace."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("At least one workspace must remain.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func usageDescription(for summary: WorkspaceSummary) -> String {
|
||||||
|
let screenCount = screenRegistry.assignedScreenCount(to: summary.id)
|
||||||
|
let tabCount = workspaceRegistry.controller(for: summary.id)?.tabs.count ?? 0
|
||||||
|
return "\(screenCount) screen\(screenCount == 1 ? "" : "s") · \(tabCount) tab\(tabCount == 1 ? "" : "s")"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sharedWorkspaceDescription(for screenCount: Int) -> String {
|
||||||
|
if screenCount > 1 {
|
||||||
|
return "This workspace is shared across \(screenCount) screens. Tab changes stay in sync across each assigned screen."
|
||||||
|
}
|
||||||
|
|
||||||
|
if screenCount == 1 {
|
||||||
|
return "This workspace is assigned to one screen. You can assign additional screens to share the same tabs."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unassigned workspaces keep their tabs and can be attached to any screen later."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectInitialWorkspaceIfNeeded() {
|
||||||
|
if selectedWorkspaceID == nil {
|
||||||
|
selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
|
||||||
|
}
|
||||||
|
renameDraft = selectedSummary?.name ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func synchronizeSelectionWithRegistry() {
|
||||||
|
guard let selectedWorkspaceID else {
|
||||||
|
selectInitialWorkspaceIfNeeded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if workspaceRegistry.summary(for: selectedWorkspaceID) == nil {
|
||||||
|
self.selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
renameDraft = selectedSummary?.name ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renameSelectedWorkspace() {
|
||||||
|
guard let effectiveSelectedWorkspaceID else { return }
|
||||||
|
workspaceRegistry.renameWorkspace(id: effectiveSelectedWorkspaceID, to: renameDraft)
|
||||||
|
renameDraft = selectedSummary?.name ?? renameDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWorkspace() {
|
||||||
|
let workspaceID = workspaceRegistry.createWorkspace()
|
||||||
|
selectedWorkspaceID = workspaceID
|
||||||
|
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func workspaceHotkeyBinding(for workspaceID: WorkspaceID) -> Binding<HotkeyBinding?> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
workspaceRegistry.summary(for: workspaceID)?.hotkey
|
||||||
|
},
|
||||||
|
set: { newValue in
|
||||||
|
workspaceRegistry.updateWorkspaceHotkey(id: workspaceID, to: newValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteSelectedWorkspace() {
|
||||||
|
guard let effectiveSelectedWorkspaceID,
|
||||||
|
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(
|
||||||
|
effectiveSelectedWorkspaceID,
|
||||||
|
preferredFallback: workspaceRegistry.defaultWorkspaceID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selectedWorkspaceID = fallbackWorkspaceID
|
||||||
|
renameDraft = workspaceRegistry.summary(for: fallbackWorkspaceID)?.name ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppSettingsControllerTests: XCTestCase {
|
||||||
|
func testTerminalSessionConfigurationIncludesShellPath() {
|
||||||
|
let store = InMemoryAppSettingsStore()
|
||||||
|
var settings = AppSettings.default
|
||||||
|
settings.terminal.shellPath = "/opt/homebrew/bin/fish"
|
||||||
|
store.storedSettings = settings
|
||||||
|
|
||||||
|
let controller = AppSettingsController(store: store)
|
||||||
|
|
||||||
|
XCTAssertEqual(controller.terminalSessionConfiguration.shellPath, "/opt/homebrew/bin/fish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTerminalSizePresetsDecodeFromTypedSettings() {
|
||||||
|
let store = InMemoryAppSettingsStore()
|
||||||
|
let presets = [
|
||||||
|
TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4))
|
||||||
|
]
|
||||||
|
var settings = AppSettings.default
|
||||||
|
settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(presets)
|
||||||
|
store.storedSettings = settings
|
||||||
|
|
||||||
|
let controller = AppSettingsController(store: store)
|
||||||
|
|
||||||
|
XCTAssertEqual(controller.terminalSizePresets, presets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class InMemoryAppSettingsStore: AppSettingsStoreType {
|
||||||
|
var storedSettings = AppSettings.default
|
||||||
|
|
||||||
|
func load() -> AppSettings {
|
||||||
|
storedSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ settings: AppSettings) {
|
||||||
|
storedSettings = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
38
CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
final class AppSettingsStoreTests: XCTestCase {
|
||||||
|
func testLoadReturnsDefaultValuesWhenStoreIsEmpty() {
|
||||||
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
defaults.removePersistentDomain(forName: #function)
|
||||||
|
defer { defaults.removePersistentDomain(forName: #function) }
|
||||||
|
|
||||||
|
let store = UserDefaultsAppSettingsStore(defaults: defaults)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.load(), .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveRoundTripsSettings() {
|
||||||
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
defaults.removePersistentDomain(forName: #function)
|
||||||
|
defer { defaults.removePersistentDomain(forName: #function) }
|
||||||
|
|
||||||
|
let store = UserDefaultsAppSettingsStore(defaults: defaults)
|
||||||
|
var settings = AppSettings.default
|
||||||
|
settings.display.showMenuBarIcon = false
|
||||||
|
settings.display.showOnAllDisplays = false
|
||||||
|
settings.display.openWidth = 900
|
||||||
|
settings.behavior.minimumHoverDuration = 0.65
|
||||||
|
settings.appearance.blurRadius = 4.5
|
||||||
|
settings.terminal.fontSize = 16
|
||||||
|
settings.terminal.themeRawValue = TerminalTheme.dracula.rawValue
|
||||||
|
settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets([
|
||||||
|
TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4))
|
||||||
|
])
|
||||||
|
settings.hotkeys.toggle = .cmdD
|
||||||
|
|
||||||
|
store.save(settings)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.load(), settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
248
CommandNotch/CommandNotchTests/NotchOrchestratorTests.swift
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import XCTest
|
||||||
|
import Combine
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotchOrchestratorTests: XCTestCase {
|
||||||
|
func testHoverOpenSchedulesOpenAfterDelay() {
|
||||||
|
let screenID = "screen-a"
|
||||||
|
let screen = makeScreenContext(screenID: screenID)
|
||||||
|
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
|
||||||
|
let host = TestNotchPresentationHost()
|
||||||
|
let scheduler = TestScheduler()
|
||||||
|
let orchestrator = NotchOrchestrator(
|
||||||
|
screenRegistry: registry,
|
||||||
|
host: host,
|
||||||
|
settingsController: makeSettingsController(),
|
||||||
|
scheduler: scheduler
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestrator.handleHoverChange(true, for: screenID)
|
||||||
|
XCTAssertEqual(screen.notchState, .closed)
|
||||||
|
|
||||||
|
scheduler.runScheduledActions()
|
||||||
|
|
||||||
|
XCTAssertEqual(screen.notchState, .open)
|
||||||
|
XCTAssertEqual(host.openedScreenIDs, [screenID])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHoverExitCancelsPendingOpen() {
|
||||||
|
let screenID = "screen-a"
|
||||||
|
let screen = makeScreenContext(screenID: screenID)
|
||||||
|
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
|
||||||
|
let host = TestNotchPresentationHost()
|
||||||
|
let scheduler = TestScheduler()
|
||||||
|
let orchestrator = NotchOrchestrator(
|
||||||
|
screenRegistry: registry,
|
||||||
|
host: host,
|
||||||
|
settingsController: makeSettingsController(),
|
||||||
|
scheduler: scheduler
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestrator.handleHoverChange(true, for: screenID)
|
||||||
|
orchestrator.handleHoverChange(false, for: screenID)
|
||||||
|
|
||||||
|
scheduler.runScheduledActions()
|
||||||
|
|
||||||
|
XCTAssertEqual(screen.notchState, .closed)
|
||||||
|
XCTAssertTrue(host.openedScreenIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloseWhileHoveringSuppressesReopenUntilHoverExit() {
|
||||||
|
let screenID = "screen-a"
|
||||||
|
let screen = makeScreenContext(screenID: screenID)
|
||||||
|
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
|
||||||
|
let host = TestNotchPresentationHost()
|
||||||
|
let scheduler = TestScheduler()
|
||||||
|
let orchestrator = NotchOrchestrator(
|
||||||
|
screenRegistry: registry,
|
||||||
|
host: host,
|
||||||
|
settingsController: makeSettingsController(),
|
||||||
|
scheduler: scheduler
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestrator.handleHoverChange(true, for: screenID)
|
||||||
|
scheduler.runScheduledActions()
|
||||||
|
XCTAssertEqual(screen.notchState, .open)
|
||||||
|
|
||||||
|
orchestrator.handleHoverChange(true, for: screenID)
|
||||||
|
orchestrator.close(screenID: screenID)
|
||||||
|
scheduler.runScheduledActions()
|
||||||
|
|
||||||
|
XCTAssertEqual(screen.notchState, .closed)
|
||||||
|
XCTAssertFalse(screen.isCloseTransitionActive)
|
||||||
|
XCTAssertTrue(screen.suppressHoverOpenUntilHoverExit)
|
||||||
|
XCTAssertEqual(host.closedScreenIDs, [screenID])
|
||||||
|
|
||||||
|
scheduler.runScheduledActions()
|
||||||
|
XCTAssertEqual(screen.notchState, .closed)
|
||||||
|
XCTAssertEqual(host.openedScreenIDs, [screenID])
|
||||||
|
|
||||||
|
orchestrator.handleHoverChange(false, for: screenID)
|
||||||
|
XCTAssertFalse(screen.suppressHoverOpenUntilHoverExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpeningSharedWorkspaceOnAnotherScreenClosesPreviousPresenter() {
|
||||||
|
let workspaceID = UUID()
|
||||||
|
let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: workspaceID)
|
||||||
|
let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: workspaceID)
|
||||||
|
let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen])
|
||||||
|
let host = TestNotchPresentationHost()
|
||||||
|
let orchestrator = NotchOrchestrator(
|
||||||
|
screenRegistry: registry,
|
||||||
|
host: host,
|
||||||
|
settingsController: makeSettingsController(),
|
||||||
|
scheduler: TestScheduler()
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestrator.open(screenID: "screen-a")
|
||||||
|
orchestrator.open(screenID: "screen-b")
|
||||||
|
|
||||||
|
XCTAssertEqual(firstScreen.notchState, .closed)
|
||||||
|
XCTAssertEqual(secondScreen.notchState, .open)
|
||||||
|
XCTAssertEqual(host.closedScreenIDs, ["screen-a"])
|
||||||
|
XCTAssertEqual(registry.presentingScreenID(for: workspaceID), "screen-b")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpeningDifferentWorkspaceDoesNotCloseOtherOpenScreen() {
|
||||||
|
let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: UUID())
|
||||||
|
let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: UUID())
|
||||||
|
let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen])
|
||||||
|
let host = TestNotchPresentationHost()
|
||||||
|
let orchestrator = NotchOrchestrator(
|
||||||
|
screenRegistry: registry,
|
||||||
|
host: host,
|
||||||
|
settingsController: makeSettingsController(),
|
||||||
|
scheduler: TestScheduler()
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestrator.open(screenID: "screen-a")
|
||||||
|
orchestrator.open(screenID: "screen-b")
|
||||||
|
|
||||||
|
XCTAssertEqual(firstScreen.notchState, .open)
|
||||||
|
XCTAssertEqual(secondScreen.notchState, .open)
|
||||||
|
XCTAssertTrue(host.closedScreenIDs.isEmpty)
|
||||||
|
XCTAssertEqual(registry.presentingScreenID(for: firstScreen.workspaceID), "screen-a")
|
||||||
|
XCTAssertEqual(registry.presentingScreenID(for: secondScreen.workspaceID), "screen-b")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeScreenContext(screenID: ScreenID, workspaceID: WorkspaceID = UUID()) -> ScreenContext {
|
||||||
|
ScreenContext(
|
||||||
|
id: screenID,
|
||||||
|
workspaceID: workspaceID,
|
||||||
|
settingsController: makeSettingsController(),
|
||||||
|
screenProvider: { _ in nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSettingsController() -> AppSettingsController {
|
||||||
|
let store = TestOrchestratorSettingsStore()
|
||||||
|
var settings = AppSettings.default
|
||||||
|
settings.behavior.openNotchOnHover = true
|
||||||
|
settings.behavior.minimumHoverDuration = 0.3
|
||||||
|
store.storedSettings = settings
|
||||||
|
return AppSettingsController(store: store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class TestScreenRegistry: ScreenRegistryType {
|
||||||
|
private let activeID: ScreenID
|
||||||
|
private var screensByID: [ScreenID: ScreenContext]
|
||||||
|
private var workspacePresenters: [WorkspaceID: ScreenID] = [:]
|
||||||
|
|
||||||
|
init(activeScreenID: ScreenID, screens: [ScreenContext]) {
|
||||||
|
self.activeID = activeScreenID
|
||||||
|
self.screensByID = Dictionary(uniqueKeysWithValues: screens.map { ($0.id, $0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func allScreens() -> [ScreenContext] {
|
||||||
|
Array(screensByID.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenContext(for id: ScreenID) -> ScreenContext? {
|
||||||
|
screensByID[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeScreenID() -> ScreenID? {
|
||||||
|
activeID
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? {
|
||||||
|
workspacePresenters[workspaceID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? {
|
||||||
|
guard let workspaceID = screensByID[screenID]?.workspaceID else { return nil }
|
||||||
|
let previous = workspacePresenters[workspaceID]
|
||||||
|
workspacePresenters[workspaceID] = screenID
|
||||||
|
return previous == screenID ? nil : previous
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseWorkspacePresentation(for screenID: ScreenID) {
|
||||||
|
workspacePresenters = workspacePresenters.filter { $0.value != screenID }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class TestNotchPresentationHost: NotchPresentationHost {
|
||||||
|
var openedScreenIDs: [ScreenID] = []
|
||||||
|
var closedScreenIDs: [ScreenID] = []
|
||||||
|
|
||||||
|
func canPresentNotch(for screenID: ScreenID) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func performOpenPresentation(for screenID: ScreenID) {
|
||||||
|
openedScreenIDs.append(screenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performClosePresentation(for screenID: ScreenID) {
|
||||||
|
closedScreenIDs.append(screenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestScheduler: SchedulerType {
|
||||||
|
private final class ScheduledAction {
|
||||||
|
let action: @MainActor () -> Void
|
||||||
|
var isCancelled = false
|
||||||
|
|
||||||
|
init(action: @escaping @MainActor () -> Void) {
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scheduledActions: [ScheduledAction] = []
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable {
|
||||||
|
let scheduledAction = ScheduledAction(action: action)
|
||||||
|
scheduledActions.append(scheduledAction)
|
||||||
|
|
||||||
|
return AnyCancellable {
|
||||||
|
scheduledAction.isCancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func runScheduledActions() {
|
||||||
|
let actions = scheduledActions
|
||||||
|
scheduledActions.removeAll()
|
||||||
|
|
||||||
|
for scheduledAction in actions where !scheduledAction.isCancelled {
|
||||||
|
scheduledAction.action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestOrchestratorSettingsStore: AppSettingsStoreType {
|
||||||
|
var storedSettings = AppSettings.default
|
||||||
|
|
||||||
|
func load() -> AppSettings {
|
||||||
|
storedSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ settings: AppSettings) {
|
||||||
|
storedSettings = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
67
CommandNotch/CommandNotchTests/ScreenContextTests.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ScreenContextTests: XCTestCase {
|
||||||
|
func testInteractiveResizeDefersPersistingSettingsUntilResizeEnds() {
|
||||||
|
let store = ScreenContextTestSettingsStore()
|
||||||
|
var settings = AppSettings.default
|
||||||
|
settings.display.openWidth = 640
|
||||||
|
settings.display.openHeight = 350
|
||||||
|
store.storedSettings = settings
|
||||||
|
|
||||||
|
let controller = AppSettingsController(store: store)
|
||||||
|
let screen = ScreenContext(
|
||||||
|
id: "screen-a",
|
||||||
|
workspaceID: UUID(),
|
||||||
|
settingsController: controller,
|
||||||
|
screenProvider: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
screen.open()
|
||||||
|
screen.beginInteractiveResize()
|
||||||
|
screen.resizeOpenNotch(to: CGSize(width: 800, height: 420))
|
||||||
|
|
||||||
|
XCTAssertEqual(screen.notchSize.width, 800)
|
||||||
|
XCTAssertEqual(screen.notchSize.height, 420)
|
||||||
|
XCTAssertEqual(controller.settings.display.openWidth, 640)
|
||||||
|
XCTAssertEqual(controller.settings.display.openHeight, 350)
|
||||||
|
|
||||||
|
screen.endInteractiveResize()
|
||||||
|
|
||||||
|
XCTAssertEqual(controller.settings.display.openWidth, 800)
|
||||||
|
XCTAssertEqual(controller.settings.display.openHeight, 420)
|
||||||
|
XCTAssertEqual(store.storedSettings.display.openWidth, 800)
|
||||||
|
XCTAssertEqual(store.storedSettings.display.openHeight, 420)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFocusLossAutoCloseSuppressionCanBeToggled() {
|
||||||
|
let controller = AppSettingsController(store: ScreenContextTestSettingsStore())
|
||||||
|
let screen = ScreenContext(
|
||||||
|
id: "screen-a",
|
||||||
|
workspaceID: UUID(),
|
||||||
|
settingsController: controller,
|
||||||
|
screenProvider: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(screen.suppressCloseOnFocusLoss)
|
||||||
|
|
||||||
|
screen.setCloseOnFocusLossSuppressed(true)
|
||||||
|
XCTAssertTrue(screen.suppressCloseOnFocusLoss)
|
||||||
|
|
||||||
|
screen.setCloseOnFocusLossSuppressed(false)
|
||||||
|
XCTAssertFalse(screen.suppressCloseOnFocusLoss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ScreenContextTestSettingsStore: AppSettingsStoreType {
|
||||||
|
var storedSettings = AppSettings.default
|
||||||
|
|
||||||
|
func load() -> AppSettings {
|
||||||
|
storedSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ settings: AppSettings) {
|
||||||
|
storedSettings = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
324
CommandNotch/CommandNotchTests/ScreenRegistryTests.swift
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ScreenRegistryTests: XCTestCase {
|
||||||
|
func testRefreshCreatesContextsForConnectedScreensUsingDefaultWorkspace() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
|
||||||
|
activeScreenIDProvider: { "screen-b" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.allScreens().map(\.id), ["screen-a", "screen-b"])
|
||||||
|
XCTAssertEqual(
|
||||||
|
registry.allScreens().map(\.workspaceID),
|
||||||
|
[workspaceRegistry.defaultWorkspaceID, workspaceRegistry.defaultWorkspaceID]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(registry.activeScreenID(), "screen-b")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAssignWorkspaceUpdatesContextAndSurvivesReconnect() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
var connectedScreenIDs = ["screen-a"]
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
connectedScreenIDsProvider: { connectedScreenIDs },
|
||||||
|
activeScreenIDProvider: { connectedScreenIDs.first },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
|
||||||
|
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
|
||||||
|
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
|
||||||
|
|
||||||
|
connectedScreenIDs = []
|
||||||
|
registry.refreshConnectedScreens()
|
||||||
|
XCTAssertNil(registry.screenContext(for: "screen-a"))
|
||||||
|
|
||||||
|
connectedScreenIDs = ["screen-a"]
|
||||||
|
registry.refreshConnectedScreens()
|
||||||
|
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeletedWorkspaceAssignmentFallsBackToDefaultWorkspace() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let assignmentStore = InMemoryScreenAssignmentStore()
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
assignmentStore: assignmentStore,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
|
||||||
|
|
||||||
|
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
|
||||||
|
workspaceRegistry.deleteWorkspace(id: reviewWorkspaceID)
|
||||||
|
registry.refreshConnectedScreens()
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
registry.screenContext(for: "screen-a")?.workspaceID,
|
||||||
|
workspaceRegistry.defaultWorkspaceID
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
assignmentStore.savedAssignments["screen-a"],
|
||||||
|
workspaceRegistry.defaultWorkspaceID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistryLoadsPersistedAssignmentsFromStore() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
let assignmentStore = InMemoryScreenAssignmentStore()
|
||||||
|
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID]
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
assignmentStore: assignmentStore,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAssignWorkspacePersistsAssignments() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let assignmentStore = InMemoryScreenAssignmentStore()
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
assignmentStore: assignmentStore,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
|
||||||
|
|
||||||
|
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
|
||||||
|
|
||||||
|
XCTAssertEqual(assignmentStore.savedAssignments["screen-a"], reviewWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWorkspaceControllerTracksAssignedWorkspace() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.workspaceController(for: "screen-a").id, workspaceRegistry.defaultWorkspaceID)
|
||||||
|
|
||||||
|
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.workspaceController(for: "screen-a").id, docsWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteWorkspaceReassignsConnectedAndPersistedScreensToFallback() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
|
||||||
|
let assignmentStore = InMemoryScreenAssignmentStore()
|
||||||
|
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID, "screen-b": docsWorkspaceID]
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
assignmentStore: assignmentStore,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
|
||||||
|
|
||||||
|
let fallbackWorkspaceID = registry.deleteWorkspace(
|
||||||
|
docsWorkspaceID,
|
||||||
|
preferredFallback: reviewWorkspaceID
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(fallbackWorkspaceID, reviewWorkspaceID)
|
||||||
|
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, reviewWorkspaceID)
|
||||||
|
XCTAssertEqual(assignmentStore.savedAssignments["screen-a"], reviewWorkspaceID)
|
||||||
|
XCTAssertEqual(assignmentStore.savedAssignments["screen-b"], reviewWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAssignedScreenCountIncludesDisconnectedAssignments() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
let assignmentStore = InMemoryScreenAssignmentStore()
|
||||||
|
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID, "screen-b": docsWorkspaceID]
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
assignmentStore: assignmentStore,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.assignedScreenCount(to: docsWorkspaceID), 2)
|
||||||
|
XCTAssertEqual(registry.assignedScreenIDs(to: docsWorkspaceID), ["screen-a", "screen-b"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClaimWorkspacePresentationTracksPresenterPerWorkspace() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
|
||||||
|
registry.assignWorkspace(docsWorkspaceID, to: "screen-b")
|
||||||
|
|
||||||
|
XCTAssertNil(registry.claimWorkspacePresentation(for: "screen-a"))
|
||||||
|
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-a")
|
||||||
|
XCTAssertEqual(registry.claimWorkspacePresentation(for: "screen-b"), "screen-a")
|
||||||
|
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-b")
|
||||||
|
|
||||||
|
registry.releaseWorkspacePresentation(for: "screen-b")
|
||||||
|
XCTAssertNil(registry.presentingScreenID(for: docsWorkspaceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAssignWorkspaceReleasesPreviousPresentationOwnership() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a"] },
|
||||||
|
activeScreenIDProvider: { "screen-a" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
|
||||||
|
_ = registry.claimWorkspacePresentation(for: "screen-a")
|
||||||
|
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-a")
|
||||||
|
|
||||||
|
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
|
||||||
|
|
||||||
|
XCTAssertNil(registry.presentingScreenID(for: docsWorkspaceID))
|
||||||
|
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, reviewWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConnectedScreenSummariesReflectActiveScreenAndAssignments() {
|
||||||
|
let workspaceRegistry = makeWorkspaceRegistry()
|
||||||
|
let settingsController = makeSettingsController()
|
||||||
|
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
|
||||||
|
let registry = ScreenRegistry(
|
||||||
|
workspaceRegistry: workspaceRegistry,
|
||||||
|
settingsController: settingsController,
|
||||||
|
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
|
||||||
|
activeScreenIDProvider: { "screen-b" },
|
||||||
|
screenLookup: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
registry.connectedScreenSummaries(),
|
||||||
|
[
|
||||||
|
ConnectedScreenSummary(
|
||||||
|
id: "screen-a",
|
||||||
|
displayName: "Screen 1",
|
||||||
|
isActive: false,
|
||||||
|
assignedWorkspaceID: docsWorkspaceID
|
||||||
|
),
|
||||||
|
ConnectedScreenSummary(
|
||||||
|
id: "screen-b",
|
||||||
|
displayName: "Screen 2",
|
||||||
|
isActive: true,
|
||||||
|
assignedWorkspaceID: workspaceRegistry.defaultWorkspaceID
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWorkspaceRegistry() -> WorkspaceRegistry {
|
||||||
|
let settingsProvider = ScreenRegistryTestSettingsProvider()
|
||||||
|
let sessionFactory = ScreenRegistryUnusedTerminalSessionFactory()
|
||||||
|
|
||||||
|
return WorkspaceRegistry(
|
||||||
|
initialWorkspaces: [],
|
||||||
|
controllerFactory: { summary in
|
||||||
|
WorkspaceController(
|
||||||
|
summary: summary,
|
||||||
|
sessionFactory: sessionFactory,
|
||||||
|
settingsProvider: settingsProvider,
|
||||||
|
bootstrapDefaultTab: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSettingsController() -> AppSettingsController {
|
||||||
|
AppSettingsController(store: TestAppSettingsStore())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class InMemoryScreenAssignmentStore: ScreenAssignmentStoreType {
|
||||||
|
var savedAssignments: [ScreenID: WorkspaceID] = [:]
|
||||||
|
|
||||||
|
func loadScreenAssignments() -> [ScreenID: WorkspaceID] {
|
||||||
|
savedAssignments
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID]) {
|
||||||
|
savedAssignments = assignments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestAppSettingsStore: AppSettingsStoreType {
|
||||||
|
private var storedSettings = AppSettings.default
|
||||||
|
|
||||||
|
func load() -> AppSettings {
|
||||||
|
storedSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ settings: AppSettings) {
|
||||||
|
storedSettings = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigurationProviding {
|
||||||
|
let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "")
|
||||||
|
let hotkeySettings = AppSettings.default.hotkeys
|
||||||
|
let terminalSizePresets = TerminalSizePresetStore.loadDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactoryType {
|
||||||
|
@MainActor
|
||||||
|
func makeSession(
|
||||||
|
fontSize: CGFloat,
|
||||||
|
theme: TerminalTheme,
|
||||||
|
shellPath: String,
|
||||||
|
initialDirectory: String?
|
||||||
|
) -> TerminalSession {
|
||||||
|
fatalError("ScreenRegistryTests should not create live terminal sessions.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon.HIToolbox
|
||||||
|
import XCTest
|
||||||
|
import SwiftTerm
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
final class TerminalCommandArrowBehaviorTests: XCTestCase {
|
||||||
|
func testCommandLeftUsesHomeSequence() {
|
||||||
|
let sequence = TerminalCommandArrowBehavior.sequence(
|
||||||
|
for: [.command],
|
||||||
|
keyCode: UInt16(kVK_LeftArrow),
|
||||||
|
applicationCursor: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(sequence, EscapeSequences.moveHomeNormal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCommandRightUsesApplicationEndSequence() {
|
||||||
|
let sequence = TerminalCommandArrowBehavior.sequence(
|
||||||
|
for: [.command],
|
||||||
|
keyCode: UInt16(kVK_RightArrow),
|
||||||
|
applicationCursor: true
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(sequence, EscapeSequences.moveEndApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionLeftKeepsSwiftTermWordNavigationPath() {
|
||||||
|
let sequence = TerminalCommandArrowBehavior.sequence(
|
||||||
|
for: [.option],
|
||||||
|
keyCode: UInt16(kVK_LeftArrow),
|
||||||
|
applicationCursor: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCommandDeleteUsesLineKillSequence() {
|
||||||
|
let sequence = TerminalCommandArrowBehavior.sequence(
|
||||||
|
for: [.command],
|
||||||
|
keyCode: UInt16(kVK_Delete),
|
||||||
|
applicationCursor: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(sequence, [0x15])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCommandLUsesClearScreenSequence() {
|
||||||
|
let sequence = TerminalCommandArrowBehavior.sequence(
|
||||||
|
for: [.command],
|
||||||
|
keyCode: UInt16(kVK_ANSI_L),
|
||||||
|
applicationCursor: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(sequence, [0x0c])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
final class WindowFrameCalculatorTests: XCTestCase {
|
||||||
|
func testClosedStateCentersWindowOnScreen() {
|
||||||
|
let frame = WindowFrameCalculator.targetFrame(
|
||||||
|
screenFrame: CGRect(x: 100, y: 50, width: 1600, height: 900),
|
||||||
|
currentWindowFrame: CGRect(x: 300, y: 0, width: 0, height: 0),
|
||||||
|
notchState: .closed,
|
||||||
|
contentSize: CGSize(width: 800, height: 300),
|
||||||
|
centerHorizontally: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(frame.origin.x, 480, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(frame.origin.y, 630, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(frame.size.width, 840, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(frame.size.height, 320, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenStateClampsDraggedFrameWithinScreenBounds() {
|
||||||
|
let frame = WindowFrameCalculator.targetFrame(
|
||||||
|
screenFrame: CGRect(x: 0, y: 0, width: 1440, height: 900),
|
||||||
|
currentWindowFrame: CGRect(x: 1200, y: 0, width: 0, height: 0),
|
||||||
|
notchState: .open,
|
||||||
|
contentSize: CGSize(width: 900, height: 320),
|
||||||
|
centerHorizontally: false
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(frame.origin.x, 500, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(frame.origin.y, 560, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
}
|
||||||
230
CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WorkspaceRegistryTests: XCTestCase {
|
||||||
|
func testRegistryCreatesDefaultWorkspaceWhenEmpty() {
|
||||||
|
let registry = makeRegistry()
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().first?.name, "Main")
|
||||||
|
XCTAssertNotNil(registry.controller(for: registry.defaultWorkspaceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateRenameAndDeleteWorkspaceUpdatesSummaries() {
|
||||||
|
let registry = makeRegistry()
|
||||||
|
let createdID = registry.createWorkspace(named: "Docs")
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main", "Docs"])
|
||||||
|
|
||||||
|
registry.renameWorkspace(id: createdID, to: "Review")
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main", "Review"])
|
||||||
|
|
||||||
|
registry.deleteWorkspace(id: createdID)
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeletingLastWorkspaceIsIgnored() {
|
||||||
|
let registry = makeRegistry()
|
||||||
|
let onlyWorkspaceID = registry.defaultWorkspaceID
|
||||||
|
|
||||||
|
registry.deleteWorkspace(id: onlyWorkspaceID)
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().first?.id, onlyWorkspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeletionFallbackPrefersRequestedWorkspaceWhenAvailable() {
|
||||||
|
let registry = makeRegistry()
|
||||||
|
let docsID = registry.createWorkspace(named: "Docs")
|
||||||
|
let reviewID = registry.createWorkspace(named: "Review")
|
||||||
|
|
||||||
|
let fallback = registry.deletionFallbackWorkspaceID(
|
||||||
|
forDeleting: docsID,
|
||||||
|
preferredFallback: reviewID
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(fallback, reviewID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistryLoadsPersistedWorkspacesFromStore() {
|
||||||
|
let store = InMemoryWorkspaceStore()
|
||||||
|
let docsID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
|
||||||
|
store.savedSummaries = [WorkspaceSummary(id: docsID, name: "Docs")]
|
||||||
|
|
||||||
|
let registry = makeRegistry(initialWorkspaces: nil, store: store)
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().first?.id, docsID)
|
||||||
|
XCTAssertEqual(registry.allWorkspaceSummaries().first?.name, "Docs")
|
||||||
|
XCTAssertEqual(registry.defaultWorkspaceID, docsID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistryPersistsWorkspaceSummaryChanges() {
|
||||||
|
let store = InMemoryWorkspaceStore()
|
||||||
|
let registry = makeRegistry(store: store)
|
||||||
|
let createdID = registry.createWorkspace(named: "Docs")
|
||||||
|
|
||||||
|
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main", "Docs"])
|
||||||
|
|
||||||
|
registry.renameWorkspace(id: createdID, to: "Review")
|
||||||
|
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main", "Review"])
|
||||||
|
|
||||||
|
registry.deleteWorkspace(id: createdID)
|
||||||
|
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateWorkspaceHotkeyPersistsAndUpdatesSummary() {
|
||||||
|
let store = InMemoryWorkspaceStore()
|
||||||
|
let registry = makeRegistry(store: store)
|
||||||
|
let docsID = registry.createWorkspace(named: "Docs")
|
||||||
|
let hotkey = HotkeyBinding.cmdShiftDigit(4)
|
||||||
|
|
||||||
|
registry.updateWorkspaceHotkey(id: docsID, to: hotkey)
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.summary(for: docsID)?.hotkey, hotkey)
|
||||||
|
XCTAssertEqual(store.savedSummaries.last?.hotkey, hotkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNextAndPreviousWorkspaceWrapAroundRegistryOrder() {
|
||||||
|
let registry = makeRegistry()
|
||||||
|
let mainID = registry.defaultWorkspaceID
|
||||||
|
let docsID = registry.createWorkspace(named: "Docs")
|
||||||
|
let reviewID = registry.createWorkspace(named: "Review")
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.nextWorkspaceID(after: mainID), docsID)
|
||||||
|
XCTAssertEqual(registry.nextWorkspaceID(after: reviewID), mainID)
|
||||||
|
XCTAssertEqual(registry.previousWorkspaceID(before: mainID), reviewID)
|
||||||
|
XCTAssertEqual(registry.previousWorkspaceID(before: docsID), mainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRegistry(
|
||||||
|
initialWorkspaces: [WorkspaceSummary]? = [],
|
||||||
|
store: (any WorkspaceStoreType)? = nil
|
||||||
|
) -> WorkspaceRegistry {
|
||||||
|
let settingsProvider = TestSettingsProvider()
|
||||||
|
let sessionFactory = UnusedTerminalSessionFactory()
|
||||||
|
|
||||||
|
return WorkspaceRegistry(
|
||||||
|
initialWorkspaces: initialWorkspaces,
|
||||||
|
store: store,
|
||||||
|
controllerFactory: { summary in
|
||||||
|
WorkspaceController(
|
||||||
|
summary: summary,
|
||||||
|
sessionFactory: sessionFactory,
|
||||||
|
settingsProvider: settingsProvider,
|
||||||
|
bootstrapDefaultTab: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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] = []
|
||||||
|
|
||||||
|
func loadWorkspaceSummaries() -> [WorkspaceSummary] {
|
||||||
|
savedSummaries
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary]) {
|
||||||
|
savedSummaries = summaries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestSettingsProvider: TerminalSessionConfigurationProviding {
|
||||||
|
let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "")
|
||||||
|
let hotkeySettings = AppSettings.default.hotkeys
|
||||||
|
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,
|
||||||
|
initialDirectory: String?
|
||||||
|
) -> TerminalSession {
|
||||||
|
fatalError("WorkspaceRegistryTests should not create live terminal sessions.")
|
||||||
|
}
|
||||||
|
}
|
||||||
40
CommandNotch/CommandNotchTests/WorkspaceStoreTests.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import CommandNotch
|
||||||
|
|
||||||
|
final class WorkspaceStoreTests: XCTestCase {
|
||||||
|
func testWorkspaceStoreRoundTripsSummaries() {
|
||||||
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
defaults.removePersistentDomain(forName: #function)
|
||||||
|
defer { defaults.removePersistentDomain(forName: #function) }
|
||||||
|
|
||||||
|
let store = UserDefaultsWorkspaceStore(defaults: defaults)
|
||||||
|
let summaries = [
|
||||||
|
WorkspaceSummary(
|
||||||
|
id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
|
||||||
|
name: "Main",
|
||||||
|
hotkey: HotkeyBinding.cmdShiftDigit(4)
|
||||||
|
),
|
||||||
|
WorkspaceSummary(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, name: "Docs")
|
||||||
|
]
|
||||||
|
|
||||||
|
store.saveWorkspaceSummaries(summaries)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.loadWorkspaceSummaries(), summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testScreenAssignmentStoreRoundTripsAssignments() {
|
||||||
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
defaults.removePersistentDomain(forName: #function)
|
||||||
|
defer { defaults.removePersistentDomain(forName: #function) }
|
||||||
|
|
||||||
|
let store = UserDefaultsScreenAssignmentStore(defaults: defaults)
|
||||||
|
let assignments: [ScreenID: WorkspaceID] = [
|
||||||
|
"screen-a": UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
|
||||||
|
"screen-b": UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
|
||||||
|
]
|
||||||
|
|
||||||
|
store.saveScreenAssignments(assignments)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.loadScreenAssignments(), assignments)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
CommandNotch/CommandNotchUITests/CommandNotchUITests.swift
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class CommandNotchUITests: XCTestCase {
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSettingsWorkspaceRenameFlow() {
|
||||||
|
let app = launchIntoSettings()
|
||||||
|
|
||||||
|
let newWorkspaceButton = app.buttons["settings.workspaces.new"]
|
||||||
|
XCTAssertTrue(newWorkspaceButton.waitForExistence(timeout: 5))
|
||||||
|
newWorkspaceButton.tap()
|
||||||
|
|
||||||
|
let nameField = app.textFields["settings.workspaces.name-field"]
|
||||||
|
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
|
||||||
|
nameField.tap()
|
||||||
|
nameField.typeKey("a", modifierFlags: .command)
|
||||||
|
nameField.typeText(XCUIKeyboardKey.delete.rawValue)
|
||||||
|
nameField.typeText("Docs")
|
||||||
|
|
||||||
|
let saveButton = app.buttons["settings.workspaces.save-name"]
|
||||||
|
XCTAssertTrue(saveButton.exists)
|
||||||
|
saveButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.staticTexts["Docs"].waitForExistence(timeout: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSettingsWorkspaceDeleteFallsBackToMain() {
|
||||||
|
let app = launchIntoSettings()
|
||||||
|
|
||||||
|
let newWorkspaceButton = app.buttons["settings.workspaces.new"]
|
||||||
|
XCTAssertTrue(newWorkspaceButton.waitForExistence(timeout: 5))
|
||||||
|
newWorkspaceButton.tap()
|
||||||
|
|
||||||
|
let nameField = app.textFields["settings.workspaces.name-field"]
|
||||||
|
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
|
||||||
|
nameField.tap()
|
||||||
|
nameField.typeKey("a", modifierFlags: .command)
|
||||||
|
nameField.typeText(XCUIKeyboardKey.delete.rawValue)
|
||||||
|
nameField.typeText("Scratch")
|
||||||
|
|
||||||
|
let saveButton = app.buttons["settings.workspaces.save-name"]
|
||||||
|
XCTAssertTrue(saveButton.exists)
|
||||||
|
saveButton.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Scratch"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let assignCurrentButton = app.buttons["settings.workspaces.assign-current"]
|
||||||
|
XCTAssertTrue(assignCurrentButton.waitForExistence(timeout: 5))
|
||||||
|
assignCurrentButton.tap()
|
||||||
|
|
||||||
|
let deleteButton = app.buttons["settings.workspaces.delete"]
|
||||||
|
XCTAssertTrue(deleteButton.waitForExistence(timeout: 5))
|
||||||
|
deleteButton.tap()
|
||||||
|
|
||||||
|
let confirmDeleteButton = app.sheets.buttons["Delete"]
|
||||||
|
XCTAssertTrue(confirmDeleteButton.waitForExistence(timeout: 5))
|
||||||
|
confirmDeleteButton.tap()
|
||||||
|
|
||||||
|
let mainValuePredicate = NSPredicate(format: "value == %@", "Main")
|
||||||
|
expectation(for: mainValuePredicate, evaluatedWith: nameField)
|
||||||
|
waitForExpectations(timeout: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenNotchLaunchShowsInteractiveControls() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = [
|
||||||
|
"--uitest-regular-activation",
|
||||||
|
"--uitest-open-notch"
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
let notch = app.descendants(matching: .any)["notch.container"]
|
||||||
|
XCTAssertTrue(notch.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let newTabButton = app.buttons["New Tab"]
|
||||||
|
XCTAssertTrue(newTabButton.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let settingsButton = app.buttons["Settings"]
|
||||||
|
XCTAssertTrue(settingsButton.waitForExistence(timeout: 5))
|
||||||
|
settingsButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.windows["CommandNotch Settings"].waitForExistence(timeout: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func launchIntoSettings() -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = [
|
||||||
|
"--uitest-regular-activation",
|
||||||
|
"--uitest-show-settings"
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
let settingsWindow = app.windows["CommandNotch Settings"]
|
||||||
|
XCTAssertTrue(settingsWindow.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let workspacesTab = app.descendants(matching: .any)["settings.tab.workspaces"]
|
||||||
|
XCTAssertTrue(workspacesTab.waitForExistence(timeout: 5))
|
||||||
|
workspacesTab.tap()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
}
|
||||||
7
CommandNotch/Downterm.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
115
CommandNotch/project.yml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
name: CommandNotch
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.commandnotch
|
||||||
|
deploymentTarget:
|
||||||
|
macOS: "14.0"
|
||||||
|
xcodeVersion: "16.0"
|
||||||
|
generateEmptyDirectories: true
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.10"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
ENABLE_HARDENED_RUNTIME: true
|
||||||
|
packages:
|
||||||
|
SwiftTerm:
|
||||||
|
url: https://github.com/migueldeicaza/SwiftTerm.git
|
||||||
|
from: "1.2.0"
|
||||||
|
schemes:
|
||||||
|
CommandNotch:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
CommandNotch: all
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- CommandNotchTests
|
||||||
|
- CommandNotchUITests
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
profile:
|
||||||
|
config: Release
|
||||||
|
analyze:
|
||||||
|
config: Debug
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
|
management:
|
||||||
|
shared: true
|
||||||
|
Release-CommandNotch:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
CommandNotch: all
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- CommandNotchTests
|
||||||
|
- CommandNotchUITests
|
||||||
|
run:
|
||||||
|
config: Release
|
||||||
|
profile:
|
||||||
|
config: Release
|
||||||
|
analyze:
|
||||||
|
config: Debug
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
|
management:
|
||||||
|
shared: true
|
||||||
|
targets:
|
||||||
|
CommandNotch:
|
||||||
|
type: application
|
||||||
|
platform: macOS
|
||||||
|
sources:
|
||||||
|
- path: CommandNotch
|
||||||
|
excludes:
|
||||||
|
- Resources/Info.plist
|
||||||
|
dependencies:
|
||||||
|
- package: SwiftTerm
|
||||||
|
info:
|
||||||
|
path: CommandNotch/Resources/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleName: CommandNotch
|
||||||
|
CFBundleDisplayName: CommandNotch
|
||||||
|
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
|
||||||
|
CFBundleVersion: "1"
|
||||||
|
CFBundleShortVersionString: "0.0.3"
|
||||||
|
CFBundlePackageType: APPL
|
||||||
|
CFBundleExecutable: CommandNotch
|
||||||
|
LSMinimumSystemVersion: "14.0"
|
||||||
|
LSUIElement: true
|
||||||
|
NSHumanReadableCopyright: "Copyright © 2026 CommandNotch. All rights reserved."
|
||||||
|
entitlements:
|
||||||
|
path: CommandNotch/Resources/CommandNotch.entitlements
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
CODE_SIGN_ENTITLEMENTS: CommandNotch/Resources/CommandNotch.entitlements
|
||||||
|
INFOPLIST_FILE: CommandNotch/Resources/Info.plist
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.app
|
||||||
|
PRODUCT_NAME: CommandNotch
|
||||||
|
COMBINE_HIDPI_IMAGES: true
|
||||||
|
CommandNotchTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: macOS
|
||||||
|
sources:
|
||||||
|
- path: CommandNotchTests
|
||||||
|
dependencies:
|
||||||
|
- target: CommandNotch
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: true
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.CommandNotchTests
|
||||||
|
PRODUCT_NAME: CommandNotchTests
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch"
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
|
CommandNotchUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: macOS
|
||||||
|
sources:
|
||||||
|
- path: CommandNotchUITests
|
||||||
|
dependencies:
|
||||||
|
- target: CommandNotch
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
DEVELOPMENT_TEAM: G698BP272N
|
||||||
|
GENERATE_INFOPLIST_FILE: true
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.CommandNotchUITests
|
||||||
|
PRODUCT_NAME: CommandNotchUITests
|
||||||
|
TEST_TARGET_NAME: CommandNotch
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 63;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
|
||||||
2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6359CF9DDF89413440300D /* NotchSettings.swift */; };
|
|
||||||
247C6F84E7ADE7AED43381E2 /* DowntermApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B671125208055E5334CB85E /* DowntermApp.swift */; };
|
|
||||||
26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589421631401C819FE1A7BA9 /* NotchViewModel.swift */; };
|
|
||||||
295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 032AECA58EA4C274BE9F3320 /* SwiftTerm */; };
|
|
||||||
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B598809B19C892470DE7268 /* TerminalSession.swift */; };
|
|
||||||
4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */; };
|
|
||||||
4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B72743F178231E0B06DD3DE /* HotkeyManager.swift */; };
|
|
||||||
5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FEFF9074A85F02C43D9408 /* NotchWindow.swift */; };
|
|
||||||
7BD705CA6A34117929B362EC /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490C53139360D970099D8F3D /* HotkeyRecorderView.swift */; };
|
|
||||||
7DE94234DC42EAB79896E176 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5CB3313B230019D0E988AFE /* SettingsView.swift */; };
|
|
||||||
7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009B75D078A5070B5EA9738 /* TabBar.swift */; };
|
|
||||||
81A912E3E16165D999882078 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0779490DE9020FBBC464BE /* AppDelegate.swift */; };
|
|
||||||
888C45C650327089EBD39B2E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BA7F4716DA3909DA8BC381 /* ContentView.swift */; };
|
|
||||||
88EBFBB2292659EA7C42A8F9 /* HotkeyBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */; };
|
|
||||||
8FC731CF99AB4C1C10C16FAB /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */; };
|
|
||||||
A70FDB7EEEB895D475ED96E8 /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5C99B7CD7F60E55844E40C /* NotchState.swift */; };
|
|
||||||
BE5E64222CF5689AC7088683 /* ScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A290D4D21D6C01A583A372 /* ScreenManager.swift */; };
|
|
||||||
C4C93F2911B41BC19A2AE934 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */; };
|
|
||||||
CC26C1677258E44F0D7B106A /* SwiftTermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47000112562615C7E59489 /* SwiftTermView.swift */; };
|
|
||||||
E9A064422790735E033E534F /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA6843B571B41986DE386F5F /* TerminalManager.swift */; };
|
|
||||||
EA604F3F38D6638C7236CDC2 /* LaunchAtLoginHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.swift */; };
|
|
||||||
F0130A88D1453CD199FA65D7 /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
02FEFF9074A85F02C43D9408 /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = "<group>"; };
|
|
||||||
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
|
||||||
0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = "<group>"; };
|
|
||||||
15A290D4D21D6C01A583A372 /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
|
|
||||||
1E47000112562615C7E59489 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
|
|
||||||
1FC09C538CBE7C2D072008B2 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
|
|
||||||
20BA7F4716DA3909DA8BC381 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
|
||||||
2C5C99B7CD7F60E55844E40C /* NotchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchState.swift; sourceTree = "<group>"; };
|
|
||||||
3B72743F178231E0B06DD3DE /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = "<group>"; };
|
|
||||||
490C53139360D970099D8F3D /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
|
|
||||||
4B671125208055E5334CB85E /* DowntermApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DowntermApp.swift; sourceTree = "<group>"; };
|
|
||||||
4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
|
|
||||||
589421631401C819FE1A7BA9 /* NotchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
|
||||||
665CFC051CF185B71199608D /* Downterm.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Downterm.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
7B598809B19C892470DE7268 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
|
|
||||||
9547A79F60E46F4521A70674 /* Downterm.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Downterm.entitlements; sourceTree = "<group>"; };
|
|
||||||
AA6359CF9DDF89413440300D /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
|
|
||||||
BA6843B571B41986DE386F5F /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
|
|
||||||
C5CB3313B230019D0E988AFE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
|
||||||
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoutWindowController.swift; sourceTree = "<group>"; };
|
|
||||||
F009B75D078A5070B5EA9738 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
|
|
||||||
F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extensions.swift"; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
6085DF2BDFFB2A99C4ABD514 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
0EF94ED46B4860C241540F0A /* Resources */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
9547A79F60E46F4521A70674 /* Downterm.entitlements */,
|
|
||||||
);
|
|
||||||
path = Resources;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
27C90448ECAC906F0DA429C0 /* Managers */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
3B72743F178231E0B06DD3DE /* HotkeyManager.swift */,
|
|
||||||
0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.swift */,
|
|
||||||
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */,
|
|
||||||
15A290D4D21D6C01A583A372 /* ScreenManager.swift */,
|
|
||||||
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */,
|
|
||||||
);
|
|
||||||
path = Managers;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
792DD4F8C079680683D8FF7A /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
665CFC051CF185B71199608D /* Downterm.app */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
869AD33E1CDEB9CBAD401BA6 /* Models */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */,
|
|
||||||
AA6359CF9DDF89413440300D /* NotchSettings.swift */,
|
|
||||||
2C5C99B7CD7F60E55844E40C /* NotchState.swift */,
|
|
||||||
589421631401C819FE1A7BA9 /* NotchViewModel.swift */,
|
|
||||||
BA6843B571B41986DE386F5F /* TerminalManager.swift */,
|
|
||||||
7B598809B19C892470DE7268 /* TerminalSession.swift */,
|
|
||||||
);
|
|
||||||
path = Models;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
8D95E0324E6AFC9E4DC0C087 /* Extensions */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */,
|
|
||||||
);
|
|
||||||
path = Extensions;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
9E1CA4816F67033BBD52D8A3 /* Downterm */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
5C0779490DE9020FBBC464BE /* AppDelegate.swift */,
|
|
||||||
20BA7F4716DA3909DA8BC381 /* ContentView.swift */,
|
|
||||||
4B671125208055E5334CB85E /* DowntermApp.swift */,
|
|
||||||
F32F526005A2589010E63C76 /* Components */,
|
|
||||||
8D95E0324E6AFC9E4DC0C087 /* Extensions */,
|
|
||||||
27C90448ECAC906F0DA429C0 /* Managers */,
|
|
||||||
869AD33E1CDEB9CBAD401BA6 /* Models */,
|
|
||||||
0EF94ED46B4860C241540F0A /* Resources */,
|
|
||||||
C2B8955F4D0A1DAA7E60326A /* Views */,
|
|
||||||
);
|
|
||||||
path = Downterm;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C2B8955F4D0A1DAA7E60326A /* Views */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C5CB3313B230019D0E988AFE /* SettingsView.swift */,
|
|
||||||
);
|
|
||||||
path = Views;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
F32F526005A2589010E63C76 /* Components */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
490C53139360D970099D8F3D /* HotkeyRecorderView.swift */,
|
|
||||||
1FC09C538CBE7C2D072008B2 /* NotchShape.swift */,
|
|
||||||
02FEFF9074A85F02C43D9408 /* NotchWindow.swift */,
|
|
||||||
1E47000112562615C7E59489 /* SwiftTermView.swift */,
|
|
||||||
F009B75D078A5070B5EA9738 /* TabBar.swift */,
|
|
||||||
);
|
|
||||||
path = Components;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
FC6F23514BFE2235BD4154E8 = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
9E1CA4816F67033BBD52D8A3 /* Downterm */,
|
|
||||||
792DD4F8C079680683D8FF7A /* Products */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
1485207FA11756EC2DF4F08B /* Downterm */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 74CB98309F5464CDCB00C63A /* Build configuration list for PBXNativeTarget "Downterm" */;
|
|
||||||
buildPhases = (
|
|
||||||
F3C6D5CD1247D246A3F6F7AB /* Sources */,
|
|
||||||
6085DF2BDFFB2A99C4ABD514 /* Frameworks */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
name = Downterm;
|
|
||||||
packageProductDependencies = (
|
|
||||||
032AECA58EA4C274BE9F3320 /* SwiftTerm */,
|
|
||||||
);
|
|
||||||
productName = Downterm;
|
|
||||||
productReference = 665CFC051CF185B71199608D /* Downterm.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
F72A983360EF3F99042A4895 /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
BuildIndependentTargetsInParallel = YES;
|
|
||||||
LastUpgradeCheck = 1600;
|
|
||||||
};
|
|
||||||
buildConfigurationList = D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "Downterm" */;
|
|
||||||
compatibilityVersion = "Xcode 14.0";
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
Base,
|
|
||||||
en,
|
|
||||||
);
|
|
||||||
mainGroup = FC6F23514BFE2235BD4154E8;
|
|
||||||
minimizedProjectReferenceProxies = 1;
|
|
||||||
packageReferences = (
|
|
||||||
80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
|
||||||
);
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
1485207FA11756EC2DF4F08B /* Downterm */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
F3C6D5CD1247D246A3F6F7AB /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
81A912E3E16165D999882078 /* AppDelegate.swift in Sources */,
|
|
||||||
888C45C650327089EBD39B2E /* ContentView.swift in Sources */,
|
|
||||||
247C6F84E7ADE7AED43381E2 /* DowntermApp.swift in Sources */,
|
|
||||||
88EBFBB2292659EA7C42A8F9 /* HotkeyBinding.swift in Sources */,
|
|
||||||
4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */,
|
|
||||||
7BD705CA6A34117929B362EC /* HotkeyRecorderView.swift in Sources */,
|
|
||||||
EA604F3F38D6638C7236CDC2 /* LaunchAtLoginHelper.swift in Sources */,
|
|
||||||
F0130A88D1453CD199FA65D7 /* NSScreen+Extensions.swift in Sources */,
|
|
||||||
2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */,
|
|
||||||
4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */,
|
|
||||||
A70FDB7EEEB895D475ED96E8 /* NotchState.swift in Sources */,
|
|
||||||
26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */,
|
|
||||||
5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */,
|
|
||||||
8FC731CF99AB4C1C10C16FAB /* PopoutWindowController.swift in Sources */,
|
|
||||||
BE5E64222CF5689AC7088683 /* ScreenManager.swift in Sources */,
|
|
||||||
7DE94234DC42EAB79896E176 /* SettingsView.swift in Sources */,
|
|
||||||
C4C93F2911B41BC19A2AE934 /* SettingsWindowController.swift in Sources */,
|
|
||||||
CC26C1677258E44F0D7B106A /* SwiftTermView.swift in Sources */,
|
|
||||||
7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */,
|
|
||||||
E9A064422790735E033E534F /* TerminalManager.swift in Sources */,
|
|
||||||
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
|
||||||
0B8C784EF064E46C44076D6B /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
CLANG_USE_OPTIMIZATION_PROFILE = YES;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = Downterm/Resources/Downterm.entitlements;
|
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
|
||||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = G698BP272N;
|
|
||||||
INFOPLIST_FILE = Downterm/Resources/Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/../Frameworks",
|
|
||||||
);
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.downterm.app;
|
|
||||||
PRODUCT_NAME = Downterm;
|
|
||||||
SDKROOT = macosx;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
3595A9212275B9AEC4448C64 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_TESTABILITY = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"DEBUG=1",
|
|
||||||
);
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = macosx;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
SWIFT_VERSION = 5.10;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
7020C02C1BDF63690CC9A3AC /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
CLANG_USE_OPTIMIZATION_PROFILE = YES;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = Downterm/Resources/Downterm.entitlements;
|
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
|
||||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = G698BP272N;
|
|
||||||
INFOPLIST_FILE = Downterm/Resources/Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/../Frameworks",
|
|
||||||
);
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.downterm.app;
|
|
||||||
PRODUCT_NAME = Downterm;
|
|
||||||
SDKROOT = macosx;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
BC741C4C821EA399B645E547 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = macosx;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
|
||||||
SWIFT_VERSION = 5.10;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
74CB98309F5464CDCB00C63A /* Build configuration list for PBXNativeTarget "Downterm" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
7020C02C1BDF63690CC9A3AC /* Debug */,
|
|
||||||
0B8C784EF064E46C44076D6B /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Debug;
|
|
||||||
};
|
|
||||||
D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "Downterm" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
3595A9212275B9AEC4448C64 /* Debug */,
|
|
||||||
BC741C4C821EA399B645E547 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Debug;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
|
||||||
80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 1.2.0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
|
||||||
032AECA58EA4C274BE9F3320 /* SwiftTerm */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
|
||||||
productName = SwiftTerm;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
|
||||||
};
|
|
||||||
rootObject = F72A983360EF3F99042A4895 /* Project object */;
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
|
||||||
BuildableName = "Downterm.app"
|
|
||||||
BlueprintName = "Downterm"
|
|
||||||
ReferencedContainer = "container:Downterm.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
|
||||||
BuildableName = "Downterm.app"
|
|
||||||
BlueprintName = "Downterm"
|
|
||||||
ReferencedContainer = "container:Downterm.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
|
||||||
BuildableName = "Downterm.app"
|
|
||||||
BlueprintName = "Downterm"
|
|
||||||
ReferencedContainer = "container:Downterm.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
|
||||||
BuildableName = "Downterm.app"
|
|
||||||
BlueprintName = "Downterm"
|
|
||||||
ReferencedContainer = "container:Downterm.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
selectedDebuggerIdentifier = ""
|
|
||||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
|
||||||
launchStyle = "1"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
|
||||||
BuildableName = "Downterm.app"
|
|
||||||
BlueprintName = "Downterm"
|
|
||||||
ReferencedContainer = "container:Downterm.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
|
||||||
BuildableName = "Downterm.app"
|
|
||||||
BlueprintName = "Downterm"
|
|
||||||
ReferencedContainer = "container:Downterm.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Application delegate that bootstraps the notch overlay system.
|
|
||||||
@MainActor
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
||||||
NotchSettings.registerDefaults()
|
|
||||||
NSApp.setActivationPolicy(.accessory)
|
|
||||||
|
|
||||||
// Sync the launch-at-login toggle with the actual system state
|
|
||||||
// in case the user toggled it from System Settings.
|
|
||||||
UserDefaults.standard.set(LaunchAtLoginHelper.isEnabled, forKey: NotchSettings.Keys.launchAtLogin)
|
|
||||||
|
|
||||||
ScreenManager.shared.start()
|
|
||||||
observeDisplayPreference()
|
|
||||||
observeSizePreferences()
|
|
||||||
observeFontSizeChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
|
||||||
ScreenManager.shared.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preference observers
|
|
||||||
|
|
||||||
/// Only rebuild windows when the display-count preference changes.
|
|
||||||
private func observeDisplayPreference() {
|
|
||||||
UserDefaults.standard.publisher(for: \.showOnAllDisplays)
|
|
||||||
.removeDuplicates()
|
|
||||||
.dropFirst()
|
|
||||||
.sink { _ in
|
|
||||||
ScreenManager.shared.rebuildWindows()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reposition (not rebuild) when any sizing preference changes.
|
|
||||||
private func observeSizePreferences() {
|
|
||||||
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
|
|
||||||
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
|
|
||||||
.sink { _ in
|
|
||||||
ScreenManager.shared.repositionWindows()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live-update terminal font size across all sessions.
|
|
||||||
private func observeFontSizeChanges() {
|
|
||||||
UserDefaults.standard.publisher(for: \.terminalFontSize)
|
|
||||||
.removeDuplicates()
|
|
||||||
.sink { newSize in
|
|
||||||
guard newSize > 0 else { return }
|
|
||||||
TerminalManager.shared.updateAllFontSizes(CGFloat(newSize))
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - KVO key paths
|
|
||||||
|
|
||||||
private extension UserDefaults {
|
|
||||||
@objc var terminalFontSize: Double {
|
|
||||||
double(forKey: NotchSettings.Keys.terminalFontSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc var showOnAllDisplays: Bool {
|
|
||||||
bool(forKey: NotchSettings.Keys.showOnAllDisplays)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
/// A clickable field that records a keyboard shortcut when focused.
|
|
||||||
/// Click it, press a key combination, and it saves the binding.
|
|
||||||
struct HotkeyRecorderView: View {
|
|
||||||
let label: String
|
|
||||||
@Binding var binding: HotkeyBinding
|
|
||||||
|
|
||||||
@State private var isRecording = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Text(label)
|
|
||||||
.frame(width: 140, alignment: .leading)
|
|
||||||
|
|
||||||
HotkeyRecorderField(binding: $binding, isRecording: $isRecording)
|
|
||||||
.frame(width: 120, height: 24)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NSViewRepresentable that captures key events when focused.
|
|
||||||
struct HotkeyRecorderField: NSViewRepresentable {
|
|
||||||
@Binding var binding: HotkeyBinding
|
|
||||||
@Binding var isRecording: Bool
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> HotkeyNSView {
|
|
||||||
let view = HotkeyNSView()
|
|
||||||
view.onKeyRecorded = { newBinding in
|
|
||||||
binding = newBinding
|
|
||||||
isRecording = false
|
|
||||||
}
|
|
||||||
view.onFocusChanged = { focused in
|
|
||||||
isRecording = focused
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
|
||||||
nsView.currentLabel = binding.displayString
|
|
||||||
nsView.showRecording = isRecording
|
|
||||||
nsView.needsDisplay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The actual NSView that handles key capture.
|
|
||||||
class HotkeyNSView: NSView {
|
|
||||||
var currentLabel: String = ""
|
|
||||||
var showRecording: Bool = false
|
|
||||||
var onKeyRecorded: ((HotkeyBinding) -> Void)?
|
|
||||||
var onFocusChanged: ((Bool) -> Void)?
|
|
||||||
|
|
||||||
override var acceptsFirstResponder: Bool { true }
|
|
||||||
|
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
|
||||||
let text = showRecording ? "Press keys…" : currentLabel
|
|
||||||
let attrs: [NSAttributedString.Key: Any] = [
|
|
||||||
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
|
|
||||||
.foregroundColor: showRecording ? NSColor.controlAccentColor : NSColor.labelColor
|
|
||||||
]
|
|
||||||
let str = NSAttributedString(string: text, attributes: attrs)
|
|
||||||
let size = str.size()
|
|
||||||
let point = NSPoint(
|
|
||||||
x: (bounds.width - size.width) / 2,
|
|
||||||
y: (bounds.height - size.height) / 2
|
|
||||||
)
|
|
||||||
str.draw(at: point)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseDown(with event: NSEvent) {
|
|
||||||
window?.makeFirstResponder(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func becomeFirstResponder() -> Bool {
|
|
||||||
onFocusChanged?(true)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override func resignFirstResponder() -> Bool {
|
|
||||||
onFocusChanged?(false)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override func keyDown(with event: NSEvent) {
|
|
||||||
guard showRecording else {
|
|
||||||
super.keyDown(with: event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
|
|
||||||
let masked = event.modifierFlags.intersection(.deviceIndependentFlagsMask).intersection(relevantFlags)
|
|
||||||
|
|
||||||
// Require at least one modifier key
|
|
||||||
guard !masked.isEmpty else { return }
|
|
||||||
|
|
||||||
let binding = HotkeyBinding(modifiers: masked.rawValue, keyCode: event.keyCode)
|
|
||||||
onKeyRecorded?(binding)
|
|
||||||
|
|
||||||
// Resign first responder after recording
|
|
||||||
window?.makeFirstResponder(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SwiftTerm
|
|
||||||
|
|
||||||
/// Main view rendered inside each NotchWindow.
|
|
||||||
///
|
|
||||||
/// Opacity strategy: EVERY element has a solid black background.
|
|
||||||
/// A single `.opacity(notchOpacity)` is applied at the outermost
|
|
||||||
/// level so everything becomes uniformly transparent — no double
|
|
||||||
/// layering, no mismatched areas.
|
|
||||||
struct ContentView: View {
|
|
||||||
|
|
||||||
@ObservedObject var vm: NotchViewModel
|
|
||||||
@ObservedObject var terminalManager: TerminalManager
|
|
||||||
|
|
||||||
// MARK: - Settings
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
|
|
||||||
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
|
|
||||||
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
|
|
||||||
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
|
|
||||||
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
|
|
||||||
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
|
|
||||||
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverSpringResponse = NotchSettings.Defaults.hoverSpringResponse
|
|
||||||
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping
|
|
||||||
|
|
||||||
@State private var hoverTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
private var hoverAnimation: Animation {
|
|
||||||
.interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentShape: NotchShape {
|
|
||||||
vm.notchState == .open
|
|
||||||
? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14))
|
|
||||||
: .closed
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Body
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
notchBody
|
|
||||||
.frame(
|
|
||||||
width: vm.notchSize.width,
|
|
||||||
height: vm.notchState == .open ? vm.notchSize.height : vm.closedNotchSize.height,
|
|
||||||
alignment: .top
|
|
||||||
)
|
|
||||||
.background(.black)
|
|
||||||
.clipShape(currentShape)
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Rectangle().fill(.black).frame(height: 1)
|
|
||||||
}
|
|
||||||
.shadow(
|
|
||||||
color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear,
|
|
||||||
radius: enableShadow ? shadowRadius : 0
|
|
||||||
)
|
|
||||||
// Single opacity control — everything inside is solid black,
|
|
||||||
// so this one modifier makes it all uniformly transparent.
|
|
||||||
.opacity(notchOpacity)
|
|
||||||
.blur(radius: blurRadius)
|
|
||||||
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState)
|
|
||||||
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.width)
|
|
||||||
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.height)
|
|
||||||
.onHover { handleHover($0) }
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var notchBody: some View {
|
|
||||||
if vm.notchState == .open {
|
|
||||||
openContent
|
|
||||||
.transition(.opacity)
|
|
||||||
} else {
|
|
||||||
closedContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var closedContent: some View {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Text(abbreviate(terminalManager.activeTitle))
|
|
||||||
.font(.system(size: 10, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.background(.black)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open layout: VStack with toolbar row on top, terminal in the middle,
|
|
||||||
/// tab bar at the bottom. Every section has a black background.
|
|
||||||
private var openContent: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Toolbar row — right-aligned, solid black
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") {
|
|
||||||
if let session = terminalManager.detachActiveTab() {
|
|
||||||
PopoutWindowController.shared.popout(session: session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toolbarButton(icon: "gearshape.fill", help: "Settings") {
|
|
||||||
SettingsWindowController.shared.showSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 6)
|
|
||||||
.padding(.trailing, 10)
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
.background(.black)
|
|
||||||
|
|
||||||
// Terminal — fills remaining space
|
|
||||||
if let session = terminalManager.activeTab {
|
|
||||||
SwiftTermView(session: session)
|
|
||||||
.id(session.id)
|
|
||||||
.padding(.leading, 10)
|
|
||||||
.padding(.trailing, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab bar
|
|
||||||
TabBar(terminalManager: terminalManager)
|
|
||||||
}
|
|
||||||
.background(.black)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func toolbarButton(icon: String, help: String, action: @escaping () -> Void) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(.white.opacity(0.45))
|
|
||||||
.padding(4)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help(help)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Hover
|
|
||||||
|
|
||||||
private func handleHover(_ hovering: Bool) {
|
|
||||||
if hovering {
|
|
||||||
withAnimation(hoverAnimation) { vm.isHovering = true }
|
|
||||||
guard openNotchOnHover, vm.notchState == .closed else { return }
|
|
||||||
|
|
||||||
hoverTask?.cancel()
|
|
||||||
hoverTask = Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(minimumHoverDuration * 1_000_000_000))
|
|
||||||
guard !Task.isCancelled, vm.isHovering else { return }
|
|
||||||
vm.requestOpen?()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hoverTask?.cancel()
|
|
||||||
withAnimation(hoverAnimation) { vm.isHovering = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func abbreviate(_ title: String) -> String {
|
|
||||||
title.count <= 30 ? title : String(title.prefix(28)) + "…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Manages one NotchWindow per connected display.
|
|
||||||
/// Routes all open/close through centralized methods that handle
|
|
||||||
/// window activation, key status, and first responder assignment
|
|
||||||
/// so the terminal can receive keyboard input.
|
|
||||||
@MainActor
|
|
||||||
class ScreenManager: ObservableObject {
|
|
||||||
|
|
||||||
static let shared = ScreenManager()
|
|
||||||
|
|
||||||
private(set) var windows: [String: NotchWindow] = [:]
|
|
||||||
private(set) var viewModels: [String: NotchViewModel] = [:]
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.showOnAllDisplays)
|
|
||||||
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
observeScreenChanges()
|
|
||||||
rebuildWindows()
|
|
||||||
setupHotkeys()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
cleanupAllWindows()
|
|
||||||
cancellables.removeAll()
|
|
||||||
HotkeyManager.shared.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Hotkey wiring
|
|
||||||
|
|
||||||
private func setupHotkeys() {
|
|
||||||
let hk = HotkeyManager.shared
|
|
||||||
let tm = TerminalManager.shared
|
|
||||||
|
|
||||||
// Callbacks are invoked on the main thread by HotkeyManager.
|
|
||||||
// MainActor.assumeIsolated lets us safely call @MainActor methods.
|
|
||||||
hk.onToggle = { [weak self] in
|
|
||||||
MainActor.assumeIsolated { self?.toggleNotchOnActiveScreen() }
|
|
||||||
}
|
|
||||||
hk.onNewTab = { MainActor.assumeIsolated { tm.newTab() } }
|
|
||||||
hk.onCloseTab = { MainActor.assumeIsolated { tm.closeActiveTab() } }
|
|
||||||
hk.onNextTab = { MainActor.assumeIsolated { tm.nextTab() } }
|
|
||||||
hk.onPreviousTab = { MainActor.assumeIsolated { tm.previousTab() } }
|
|
||||||
hk.onDetachTab = { [weak self] in
|
|
||||||
MainActor.assumeIsolated { self?.detachActiveTab() }
|
|
||||||
}
|
|
||||||
hk.onSwitchToTab = { index in
|
|
||||||
MainActor.assumeIsolated { tm.switchToTab(at: index) }
|
|
||||||
}
|
|
||||||
|
|
||||||
hk.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Toggle
|
|
||||||
|
|
||||||
func toggleNotchOnActiveScreen() {
|
|
||||||
let mouseLocation = NSEvent.mouseLocation
|
|
||||||
let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }
|
|
||||||
?? NSScreen.main
|
|
||||||
guard let screen = targetScreen else { return }
|
|
||||||
let uuid = screen.displayUUID
|
|
||||||
|
|
||||||
// Close any other open notch first
|
|
||||||
for (otherUUID, otherVM) in viewModels where otherUUID != uuid {
|
|
||||||
if otherVM.notchState == .open {
|
|
||||||
closeNotch(screenUUID: otherUUID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let vm = viewModels[uuid] {
|
|
||||||
if vm.notchState == .open {
|
|
||||||
closeNotch(screenUUID: uuid)
|
|
||||||
} else {
|
|
||||||
openNotch(screenUUID: uuid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Open / Close
|
|
||||||
|
|
||||||
func openNotch(screenUUID: String) {
|
|
||||||
guard let vm = viewModels[screenUUID],
|
|
||||||
let window = windows[screenUUID] else { return }
|
|
||||||
|
|
||||||
withAnimation(vm.openAnimation) {
|
|
||||||
vm.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.isNotchOpen = true
|
|
||||||
HotkeyManager.shared.isNotchOpen = true
|
|
||||||
|
|
||||||
// Activate the app so the window can become key.
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
window.makeKeyAndOrderFront(nil)
|
|
||||||
|
|
||||||
// After layout settles, push keyboard focus to the terminal view
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
||||||
if let tv = TerminalManager.shared.activeTab?.terminalView {
|
|
||||||
window.makeFirstResponder(tv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func closeNotch(screenUUID: String) {
|
|
||||||
guard let vm = viewModels[screenUUID],
|
|
||||||
let window = windows[screenUUID] else { return }
|
|
||||||
|
|
||||||
withAnimation(vm.closeAnimation) {
|
|
||||||
vm.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.isNotchOpen = false
|
|
||||||
HotkeyManager.shared.isNotchOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func detachActiveTab() {
|
|
||||||
if let session = TerminalManager.shared.detachActiveTab() {
|
|
||||||
PopoutWindowController.shared.popout(session: session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Window creation
|
|
||||||
|
|
||||||
func rebuildWindows() {
|
|
||||||
cleanupAllWindows()
|
|
||||||
|
|
||||||
let screens: [NSScreen]
|
|
||||||
if showOnAllDisplays {
|
|
||||||
screens = NSScreen.screens
|
|
||||||
} else {
|
|
||||||
screens = [NSScreen.main].compactMap { $0 }
|
|
||||||
}
|
|
||||||
for screen in screens {
|
|
||||||
createWindow(for: screen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createWindow(for screen: NSScreen) {
|
|
||||||
let uuid = screen.displayUUID
|
|
||||||
let vm = NotchViewModel(screenUUID: uuid)
|
|
||||||
|
|
||||||
let shadowPadding: CGFloat = 20
|
|
||||||
let openSize = vm.openNotchSize
|
|
||||||
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
|
|
||||||
let windowHeight = openSize.height + shadowPadding
|
|
||||||
|
|
||||||
let windowRect = NSRect(
|
|
||||||
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
|
|
||||||
y: screen.frame.origin.y + screen.frame.height - windowHeight,
|
|
||||||
width: windowWidth,
|
|
||||||
height: windowHeight
|
|
||||||
)
|
|
||||||
|
|
||||||
let window = NotchWindow(
|
|
||||||
contentRect: windowRect,
|
|
||||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false
|
|
||||||
)
|
|
||||||
|
|
||||||
// Close the notch when the window loses focus
|
|
||||||
window.onResignKey = { [weak self] in
|
|
||||||
self?.closeNotch(screenUUID: uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire the ViewModel callbacks so ContentView routes through us
|
|
||||||
vm.requestOpen = { [weak self] in
|
|
||||||
self?.openNotch(screenUUID: uuid)
|
|
||||||
}
|
|
||||||
vm.requestClose = { [weak self] in
|
|
||||||
self?.closeNotch(screenUUID: uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
let hostingView = NSHostingView(
|
|
||||||
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
)
|
|
||||||
hostingView.frame = NSRect(origin: .zero, size: windowRect.size)
|
|
||||||
window.contentView = hostingView
|
|
||||||
|
|
||||||
window.setFrame(windowRect, display: true)
|
|
||||||
window.orderFrontRegardless()
|
|
||||||
|
|
||||||
windows[uuid] = window
|
|
||||||
viewModels[uuid] = vm
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Repositioning
|
|
||||||
|
|
||||||
func repositionWindows() {
|
|
||||||
for (uuid, window) in windows {
|
|
||||||
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == uuid }) else { continue }
|
|
||||||
guard let vm = viewModels[uuid] else { continue }
|
|
||||||
|
|
||||||
vm.refreshClosedSize()
|
|
||||||
|
|
||||||
let shadowPadding: CGFloat = 20
|
|
||||||
let openSize = vm.openNotchSize
|
|
||||||
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
|
|
||||||
let windowHeight = openSize.height + shadowPadding
|
|
||||||
|
|
||||||
let newFrame = NSRect(
|
|
||||||
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
|
|
||||||
y: screen.frame.origin.y + screen.frame.height - windowHeight,
|
|
||||||
width: windowWidth,
|
|
||||||
height: windowHeight
|
|
||||||
)
|
|
||||||
window.setFrame(newFrame, display: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cleanup
|
|
||||||
|
|
||||||
private func cleanupAllWindows() {
|
|
||||||
for (_, window) in windows {
|
|
||||||
window.orderOut(nil)
|
|
||||||
window.close()
|
|
||||||
}
|
|
||||||
windows.removeAll()
|
|
||||||
viewModels.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Screen observation
|
|
||||||
|
|
||||||
private func observeScreenChanges() {
|
|
||||||
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
|
||||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
|
||||||
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleScreenConfigurationChange() {
|
|
||||||
let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID })
|
|
||||||
let knownUUIDs = Set(windows.keys)
|
|
||||||
if currentUUIDs != knownUUIDs {
|
|
||||||
rebuildWindows()
|
|
||||||
} else {
|
|
||||||
repositionWindows()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Per-screen observable state that drives the notch UI.
|
|
||||||
@MainActor
|
|
||||||
class NotchViewModel: ObservableObject {
|
|
||||||
|
|
||||||
let screenUUID: String
|
|
||||||
|
|
||||||
@Published var notchState: NotchState = .closed
|
|
||||||
@Published var notchSize: CGSize
|
|
||||||
@Published var closedNotchSize: CGSize
|
|
||||||
@Published var isHovering: Bool = false
|
|
||||||
|
|
||||||
let terminalManager = TerminalManager.shared
|
|
||||||
|
|
||||||
/// Set by ScreenManager — routes open/close through proper
|
|
||||||
/// window activation so the terminal receives keyboard input.
|
|
||||||
var requestOpen: (() -> Void)?
|
|
||||||
var requestClose: (() -> Void)?
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
|
||||||
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openSpringResponse = NotchSettings.Defaults.openSpringResponse
|
|
||||||
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping
|
|
||||||
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse
|
|
||||||
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping
|
|
||||||
|
|
||||||
var openAnimation: Animation {
|
|
||||||
.spring(response: openSpringResponse, dampingFraction: openSpringDamping)
|
|
||||||
}
|
|
||||||
var closeAnimation: Animation {
|
|
||||||
.spring(response: closeSpringResponse, dampingFraction: closeSpringDamping)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(screenUUID: String) {
|
|
||||||
self.screenUUID = screenUUID
|
|
||||||
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
|
|
||||||
let closed = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
|
|
||||||
self.closedNotchSize = closed
|
|
||||||
self.notchSize = closed
|
|
||||||
}
|
|
||||||
|
|
||||||
func open() {
|
|
||||||
notchSize = CGSize(width: openWidth, height: openHeight)
|
|
||||||
notchState = .open
|
|
||||||
}
|
|
||||||
|
|
||||||
func close() {
|
|
||||||
refreshClosedSize()
|
|
||||||
notchSize = closedNotchSize
|
|
||||||
notchState = .closed
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshClosedSize() {
|
|
||||||
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
|
|
||||||
closedNotchSize = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
var openNotchSize: CGSize {
|
|
||||||
CGSize(width: openWidth, height: openHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Manages multiple terminal tabs. Singleton shared across all screens —
|
|
||||||
/// whichever notch is currently open displays these tabs.
|
|
||||||
@MainActor
|
|
||||||
class TerminalManager: ObservableObject {
|
|
||||||
|
|
||||||
static let shared = TerminalManager()
|
|
||||||
|
|
||||||
@Published var tabs: [TerminalSession] = []
|
|
||||||
@Published var activeTabIndex: Int = 0
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.terminalFontSize)
|
|
||||||
private var fontSize: Double = NotchSettings.Defaults.terminalFontSize
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
newTab()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Active tab
|
|
||||||
|
|
||||||
var activeTab: TerminalSession? {
|
|
||||||
guard tabs.indices.contains(activeTabIndex) else { return nil }
|
|
||||||
return tabs[activeTabIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short title for the closed notch bar — the active tab's process name.
|
|
||||||
var activeTitle: String {
|
|
||||||
activeTab?.title ?? "shell"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tab operations
|
|
||||||
|
|
||||||
func newTab() {
|
|
||||||
let session = TerminalSession(fontSize: CGFloat(fontSize))
|
|
||||||
|
|
||||||
// Forward title changes to trigger view updates in this manager
|
|
||||||
session.$title
|
|
||||||
.receive(on: RunLoop.main)
|
|
||||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
tabs.append(session)
|
|
||||||
activeTabIndex = tabs.count - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func closeTab(at index: Int) {
|
|
||||||
guard tabs.indices.contains(index) else { return }
|
|
||||||
tabs[index].terminate()
|
|
||||||
tabs.remove(at: index)
|
|
||||||
|
|
||||||
// Adjust active index
|
|
||||||
if tabs.isEmpty {
|
|
||||||
newTab()
|
|
||||||
} else if activeTabIndex >= tabs.count {
|
|
||||||
activeTabIndex = tabs.count - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func closeActiveTab() {
|
|
||||||
closeTab(at: activeTabIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
func switchToTab(at index: Int) {
|
|
||||||
guard tabs.indices.contains(index) else { return }
|
|
||||||
activeTabIndex = index
|
|
||||||
}
|
|
||||||
|
|
||||||
func nextTab() {
|
|
||||||
guard tabs.count > 1 else { return }
|
|
||||||
activeTabIndex = (activeTabIndex + 1) % tabs.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func previousTab() {
|
|
||||||
guard tabs.count > 1 else { return }
|
|
||||||
activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the tab at the given index and returns the session so it
|
|
||||||
/// can be hosted in a pop-out window.
|
|
||||||
func detachTab(at index: Int) -> TerminalSession? {
|
|
||||||
guard tabs.indices.contains(index) else { return nil }
|
|
||||||
let session = tabs.remove(at: index)
|
|
||||||
|
|
||||||
if tabs.isEmpty {
|
|
||||||
newTab()
|
|
||||||
} else if activeTabIndex >= tabs.count {
|
|
||||||
activeTabIndex = tabs.count - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
func detachActiveTab() -> TerminalSession? {
|
|
||||||
detachTab(at: activeTabIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates font size on all existing terminal sessions.
|
|
||||||
func updateAllFontSizes(_ size: CGFloat) {
|
|
||||||
for tab in tabs {
|
|
||||||
tab.updateFontSize(size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
|
|
||||||
struct SettingsView: View {
|
|
||||||
|
|
||||||
@State private var selectedTab: SettingsTab = .general
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationSplitView {
|
|
||||||
List(SettingsTab.allCases, selection: $selectedTab) { tab in
|
|
||||||
Label(tab.label, systemImage: tab.icon)
|
|
||||||
.tag(tab)
|
|
||||||
}
|
|
||||||
.listStyle(.sidebar)
|
|
||||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
|
||||||
} detail: {
|
|
||||||
ScrollView {
|
|
||||||
detailView.padding()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var detailView: some View {
|
|
||||||
switch selectedTab {
|
|
||||||
case .general: GeneralSettingsView()
|
|
||||||
case .appearance: AppearanceSettingsView()
|
|
||||||
case .animation: AnimationSettingsView()
|
|
||||||
case .terminal: TerminalSettingsView()
|
|
||||||
case .hotkeys: HotkeySettingsView()
|
|
||||||
case .about: AboutSettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tabs
|
|
||||||
|
|
||||||
enum SettingsTab: String, CaseIterable, Identifiable {
|
|
||||||
case general, appearance, animation, terminal, hotkeys, about
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .general: return "General"
|
|
||||||
case .appearance: return "Appearance"
|
|
||||||
case .animation: return "Animation"
|
|
||||||
case .terminal: return "Terminal"
|
|
||||||
case .hotkeys: return "Hotkeys"
|
|
||||||
case .about: return "About"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon: String {
|
|
||||||
switch self {
|
|
||||||
case .general: return "gearshape"
|
|
||||||
case .appearance: return "paintbrush"
|
|
||||||
case .animation: return "bolt.fill"
|
|
||||||
case .terminal: return "terminal"
|
|
||||||
case .hotkeys: return "keyboard"
|
|
||||||
case .about: return "info.circle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - General
|
|
||||||
|
|
||||||
struct GeneralSettingsView: View {
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
|
|
||||||
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
|
|
||||||
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
|
|
||||||
@AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
|
|
||||||
@AppStorage(NotchSettings.Keys.launchAtLogin) private var launchAtLogin = NotchSettings.Defaults.launchAtLogin
|
|
||||||
@AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures
|
|
||||||
@AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode
|
|
||||||
@AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight
|
|
||||||
@AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode
|
|
||||||
@AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
|
||||||
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("Display") {
|
|
||||||
Toggle("Show on all displays", isOn: $showOnAllDisplays)
|
|
||||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
|
||||||
Toggle("Launch at login", isOn: $launchAtLogin)
|
|
||||||
.onChange(of: launchAtLogin) { _, newValue in
|
|
||||||
LaunchAtLoginHelper.setEnabled(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Hover Behavior") {
|
|
||||||
Toggle("Open notch on hover", isOn: $openNotchOnHover)
|
|
||||||
if openNotchOnHover {
|
|
||||||
HStack {
|
|
||||||
Text("Hover delay")
|
|
||||||
Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05)
|
|
||||||
Text(String(format: "%.2fs", minimumHoverDuration))
|
|
||||||
.monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Gestures") {
|
|
||||||
Toggle("Enable gestures", isOn: $enableGestures)
|
|
||||||
if enableGestures {
|
|
||||||
HStack {
|
|
||||||
Text("Sensitivity")
|
|
||||||
Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05)
|
|
||||||
Text(String(format: "%.2f", gestureSensitivity))
|
|
||||||
.monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Closed Notch Size") {
|
|
||||||
Picker("Notch screens", selection: $notchHeightMode) {
|
|
||||||
ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
|
|
||||||
}
|
|
||||||
if notchHeightMode == NotchHeightMode.custom.rawValue {
|
|
||||||
HStack {
|
|
||||||
Text("Custom height")
|
|
||||||
Slider(value: $notchHeight, in: 16...64, step: 1)
|
|
||||||
Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Picker("Non-notch screens", selection: $nonNotchHeightMode) {
|
|
||||||
ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
|
|
||||||
}
|
|
||||||
if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
|
|
||||||
HStack {
|
|
||||||
Text("Custom height")
|
|
||||||
Slider(value: $nonNotchHeight, in: 16...64, step: 1)
|
|
||||||
Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Open Notch Size") {
|
|
||||||
HStack {
|
|
||||||
Text("Width")
|
|
||||||
Slider(value: $openWidth, in: 300...1200, step: 10)
|
|
||||||
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Text("Height")
|
|
||||||
Slider(value: $openHeight, in: 100...600, step: 10)
|
|
||||||
Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Appearance
|
|
||||||
|
|
||||||
struct AppearanceSettingsView: View {
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
|
|
||||||
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
|
|
||||||
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
|
|
||||||
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
|
|
||||||
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
|
|
||||||
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("Shadow") {
|
|
||||||
Toggle("Enable shadow", isOn: $enableShadow)
|
|
||||||
if enableShadow {
|
|
||||||
HStack {
|
|
||||||
Text("Radius")
|
|
||||||
Slider(value: $shadowRadius, in: 0...30, step: 1)
|
|
||||||
Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40)
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Text("Opacity")
|
|
||||||
Slider(value: $shadowOpacity, in: 0...1, step: 0.05)
|
|
||||||
Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Section("Shape") {
|
|
||||||
Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling)
|
|
||||||
}
|
|
||||||
Section("Opacity & Blur") {
|
|
||||||
HStack {
|
|
||||||
Text("Notch opacity")
|
|
||||||
Slider(value: $notchOpacity, in: 0...1, step: 0.05)
|
|
||||||
Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Text("Blur radius")
|
|
||||||
Slider(value: $blurRadius, in: 0...20, step: 0.5)
|
|
||||||
Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Animation
|
|
||||||
|
|
||||||
struct AnimationSettingsView: View {
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse
|
|
||||||
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping
|
|
||||||
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse
|
|
||||||
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping
|
|
||||||
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse
|
|
||||||
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("Open Animation") {
|
|
||||||
springControls(response: $openResponse, damping: $openDamping)
|
|
||||||
}
|
|
||||||
Section("Close Animation") {
|
|
||||||
springControls(response: $closeResponse, damping: $closeDamping)
|
|
||||||
}
|
|
||||||
Section("Hover Animation") {
|
|
||||||
springControls(response: $hoverResponse, damping: $hoverDamping)
|
|
||||||
}
|
|
||||||
Section {
|
|
||||||
Button("Reset to Defaults") {
|
|
||||||
openResponse = NotchSettings.Defaults.openSpringResponse
|
|
||||||
openDamping = NotchSettings.Defaults.openSpringDamping
|
|
||||||
closeResponse = NotchSettings.Defaults.closeSpringResponse
|
|
||||||
closeDamping = NotchSettings.Defaults.closeSpringDamping
|
|
||||||
hoverResponse = NotchSettings.Defaults.hoverSpringResponse
|
|
||||||
hoverDamping = NotchSettings.Defaults.hoverSpringDamping
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
|
|
||||||
HStack {
|
|
||||||
Text("Response")
|
|
||||||
Slider(value: response, in: 0.1...1.5, step: 0.01)
|
|
||||||
Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Text("Damping")
|
|
||||||
Slider(value: damping, in: 0.1...1.5, step: 0.01)
|
|
||||||
Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Terminal
|
|
||||||
|
|
||||||
struct TerminalSettingsView: View {
|
|
||||||
|
|
||||||
@AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
|
|
||||||
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("Font") {
|
|
||||||
HStack {
|
|
||||||
Text("Font size")
|
|
||||||
Slider(value: $fontSize, in: 8...28, step: 1)
|
|
||||||
Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Section("Shell") {
|
|
||||||
TextField("Shell path (empty = $SHELL)", text: $shellPath)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Hotkeys
|
|
||||||
|
|
||||||
struct HotkeySettingsView: View {
|
|
||||||
|
|
||||||
@State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn)
|
|
||||||
@State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT)
|
|
||||||
@State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW)
|
|
||||||
@State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB)
|
|
||||||
@State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB)
|
|
||||||
@State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD)
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("Global") {
|
|
||||||
HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle))
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Terminal Tabs (active when notch is open)") {
|
|
||||||
HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab))
|
|
||||||
HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab))
|
|
||||||
HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab))
|
|
||||||
HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab))
|
|
||||||
HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab))
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Text("⌘1–9 always switch to tab by number.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button("Reset to Defaults") {
|
|
||||||
toggleBinding = .cmdReturn
|
|
||||||
newTabBinding = .cmdT
|
|
||||||
closeTabBinding = .cmdW
|
|
||||||
nextTabBinding = .cmdShiftRB
|
|
||||||
prevTabBinding = .cmdShiftLB
|
|
||||||
detachBinding = .cmdD
|
|
||||||
|
|
||||||
save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle)
|
|
||||||
save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab)
|
|
||||||
save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab)
|
|
||||||
save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab)
|
|
||||||
save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab)
|
|
||||||
save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a binding that saves to UserDefaults on every change.
|
|
||||||
private func bindAndSave(_ state: Binding<HotkeyBinding>, key: String) -> Binding<HotkeyBinding> {
|
|
||||||
Binding(
|
|
||||||
get: { state.wrappedValue },
|
|
||||||
set: { newValue in
|
|
||||||
state.wrappedValue = newValue
|
|
||||||
save(newValue, key: key)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func save(_ binding: HotkeyBinding, key: String) {
|
|
||||||
UserDefaults.standard.set(binding.toJSON(), forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding {
|
|
||||||
guard let json = UserDefaults.standard.string(forKey: key),
|
|
||||||
let b = HotkeyBinding.fromJSON(json) else { return fallback }
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - About
|
|
||||||
|
|
||||||
struct AboutSettingsView: View {
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "terminal")
|
|
||||||
.font(.system(size: 64))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("Downterm")
|
|
||||||
.font(.largeTitle.bold())
|
|
||||||
Text("Version 0.3.0")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("A drop-down terminal that lives in your notch.")
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.top, 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
name: Downterm
|
|
||||||
options:
|
|
||||||
bundleIdPrefix: com.downterm
|
|
||||||
deploymentTarget:
|
|
||||||
macOS: "14.0"
|
|
||||||
xcodeVersion: "16.0"
|
|
||||||
generateEmptyDirectories: true
|
|
||||||
settings:
|
|
||||||
base:
|
|
||||||
SWIFT_VERSION: "5.10"
|
|
||||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
|
||||||
ENABLE_HARDENED_RUNTIME: true
|
|
||||||
packages:
|
|
||||||
SwiftTerm:
|
|
||||||
url: https://github.com/migueldeicaza/SwiftTerm.git
|
|
||||||
from: "1.2.0"
|
|
||||||
targets:
|
|
||||||
Downterm:
|
|
||||||
type: application
|
|
||||||
platform: macOS
|
|
||||||
sources:
|
|
||||||
- path: Downterm
|
|
||||||
excludes:
|
|
||||||
- Resources/Info.plist
|
|
||||||
dependencies:
|
|
||||||
- package: SwiftTerm
|
|
||||||
info:
|
|
||||||
path: Downterm/Resources/Info.plist
|
|
||||||
properties:
|
|
||||||
CFBundleName: Downterm
|
|
||||||
CFBundleDisplayName: Downterm
|
|
||||||
CFBundleIdentifier: com.downterm.app
|
|
||||||
CFBundleVersion: "1"
|
|
||||||
CFBundleShortVersionString: "0.2.0"
|
|
||||||
CFBundlePackageType: APPL
|
|
||||||
CFBundleExecutable: Downterm
|
|
||||||
LSMinimumSystemVersion: "14.0"
|
|
||||||
LSUIElement: true
|
|
||||||
NSHumanReadableCopyright: "Copyright © 2026 Downterm. All rights reserved."
|
|
||||||
entitlements:
|
|
||||||
path: Downterm/Resources/Downterm.entitlements
|
|
||||||
settings:
|
|
||||||
base:
|
|
||||||
CODE_SIGN_ENTITLEMENTS: Downterm/Resources/Downterm.entitlements
|
|
||||||
INFOPLIST_FILE: Downterm/Resources/Info.plist
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.downterm.app
|
|
||||||
PRODUCT_NAME: Downterm
|
|
||||||
COMBINE_HIDPI_IMAGES: true
|
|
||||||
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).
|
||||||
885
docs/workspace-architecture-spec.md
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
# Workspace Architecture Spec
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the target architecture for refactoring CommandNotch into a maintainable, testable macOS app with:
|
||||||
|
|
||||||
|
- explicit runtime state ownership
|
||||||
|
- virtual workspaces shared across screens
|
||||||
|
- typed settings instead of `UserDefaults` as the event bus
|
||||||
|
- reduced singleton coupling
|
||||||
|
- a realistic testing strategy for logic, integration, and UI
|
||||||
|
|
||||||
|
This is the implementation reference for the refactor.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Preserve the current product behavior where possible.
|
||||||
|
- Support multiple virtual workspaces.
|
||||||
|
- Allow multiple screens to point at the same workspace.
|
||||||
|
- Allow screens to switch workspaces independently.
|
||||||
|
- Keep screen-specific UI state local to each screen.
|
||||||
|
- Make business logic unit testable without real `NSWindow`, `NSScreen`, or `UserDefaults`.
|
||||||
|
- Keep AppKit/SwiftUI at the edges.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Multi-window rendering of the same live terminal instance at the same time.
|
||||||
|
- Full state restoration of shell process contents across launches.
|
||||||
|
- Perfect backward compatibility for internal architecture.
|
||||||
|
- Solving all visual polish issues during the refactor.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- A `TerminalView` cannot safely belong to more than one window/view hierarchy at once.
|
||||||
|
- A `LocalProcess`-backed session is inherently stateful and UI-coupled through SwiftTerm.
|
||||||
|
- The app is macOS-only and already depends on AppKit + SwiftUI + SwiftTerm.
|
||||||
|
|
||||||
|
## Target Mental Model
|
||||||
|
|
||||||
|
There are three layers of state:
|
||||||
|
|
||||||
|
1. Global app state
|
||||||
|
Global settings, hotkeys, launch behavior, workspace metadata, screen assignments.
|
||||||
|
2. Workspace state
|
||||||
|
Tabs, active tab, terminal sessions, workspace title, workspace-local behavior.
|
||||||
|
3. Screen state
|
||||||
|
Which workspace a screen is viewing, notch geometry, open/closed state, hover state, focus state.
|
||||||
|
|
||||||
|
The current app blurs these boundaries. The refactor makes them explicit.
|
||||||
|
|
||||||
|
## High-Level Architecture
|
||||||
|
|
||||||
|
### Composition Root
|
||||||
|
|
||||||
|
`AppDelegate` becomes the composition root. It wires concrete implementations together and passes them into coordinators/controllers. It should be the only place that knows about most concrete types.
|
||||||
|
|
||||||
|
### Core Modules
|
||||||
|
|
||||||
|
- `AppSettingsStore`
|
||||||
|
Loads and saves global settings. Publishes typed changes.
|
||||||
|
- `WorkspaceRegistry`
|
||||||
|
Owns all workspaces and workspace metadata.
|
||||||
|
- `ScreenRegistry`
|
||||||
|
Tracks connected screens and their screen-local state.
|
||||||
|
- `NotchOrchestrator`
|
||||||
|
Coordinates notch lifecycle per screen.
|
||||||
|
- `WindowCoordinator`
|
||||||
|
Owns AppKit windows/panels and binds them to screen models.
|
||||||
|
- `HotkeyService`
|
||||||
|
Registers global and local hotkeys and emits typed intents.
|
||||||
|
- `SettingsCoordinator`
|
||||||
|
Presents settings UI.
|
||||||
|
- `PopoutCoordinator`
|
||||||
|
Presents detached terminal windows.
|
||||||
|
|
||||||
|
### UI Boundaries
|
||||||
|
|
||||||
|
- SwiftUI views render state and emit intents only.
|
||||||
|
- AppKit controllers own `NSWindow`, `NSPanel`, `NSHostingView`.
|
||||||
|
- SwiftTerm integration is behind a terminal session abstraction.
|
||||||
|
|
||||||
|
## Ownership Model
|
||||||
|
|
||||||
|
### AppSettings
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- app-wide appearance settings
|
||||||
|
- hotkey bindings
|
||||||
|
- launch-at-login
|
||||||
|
- menu bar visibility
|
||||||
|
- default workspace behavior
|
||||||
|
- workspace assignment persistence
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- transient window geometry
|
||||||
|
- active terminal sessions
|
||||||
|
- per-screen hover/open state
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- `workspaceID`
|
||||||
|
- display name
|
||||||
|
- ordered tabs
|
||||||
|
- active tab selection
|
||||||
|
- detached tab metadata if retained
|
||||||
|
- workspace-local terminal state
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- screen assignment
|
||||||
|
- notch geometry
|
||||||
|
- whether a screen is open or closed
|
||||||
|
|
||||||
|
### ScreenContext
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- `screenID`
|
||||||
|
- assigned `workspaceID`
|
||||||
|
- notch presentation state
|
||||||
|
- current notch frame/size
|
||||||
|
- hover state
|
||||||
|
- local focus state
|
||||||
|
- local transition state
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- tabs
|
||||||
|
- terminal session collection
|
||||||
|
- app-wide settings
|
||||||
|
|
||||||
|
## Required Domain Types
|
||||||
|
|
||||||
|
### Identifiers
|
||||||
|
|
||||||
|
```swift
|
||||||
|
typealias WorkspaceID = UUID
|
||||||
|
typealias ScreenID = String
|
||||||
|
typealias TabID = UUID
|
||||||
|
typealias SessionID = UUID
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Settings
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct AppSettings: Equatable, Codable {
|
||||||
|
var showMenuBarIcon: Bool
|
||||||
|
var showOnAllDisplays: Bool
|
||||||
|
var launchAtLogin: Bool
|
||||||
|
|
||||||
|
var appearance: AppearanceSettings
|
||||||
|
var animation: AnimationSettings
|
||||||
|
var terminal: TerminalSettings
|
||||||
|
var hotkeys: HotkeySettings
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workspace Summary
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct WorkspaceSummary: Equatable, Codable, Identifiable {
|
||||||
|
var id: WorkspaceID
|
||||||
|
var name: String
|
||||||
|
var createdAt: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workspace Assignment
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct ScreenWorkspaceAssignment: Equatable, Codable {
|
||||||
|
var screenID: ScreenID
|
||||||
|
var workspaceID: WorkspaceID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screen UI State
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct ScreenUIState: Equatable {
|
||||||
|
var screenID: ScreenID
|
||||||
|
var workspaceID: WorkspaceID
|
||||||
|
var notchState: NotchPresentationState
|
||||||
|
var notchSize: CGSize
|
||||||
|
var closedNotchSize: CGSize
|
||||||
|
var isHovering: Bool
|
||||||
|
var isFocused: Bool
|
||||||
|
var transitionState: NotchTransitionState
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workspace State
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct WorkspaceState: Identifiable {
|
||||||
|
var id: WorkspaceID
|
||||||
|
var name: String
|
||||||
|
var tabs: [TerminalTabState]
|
||||||
|
var activeTabID: TabID?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tab State
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct TerminalTabState: Identifiable {
|
||||||
|
var id: TabID
|
||||||
|
var sessionID: SessionID
|
||||||
|
var title: String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notch Lifecycle State
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum NotchPresentationState: Equatable {
|
||||||
|
case closed
|
||||||
|
case open
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotchTransitionState: Equatable {
|
||||||
|
case idle
|
||||||
|
case opening
|
||||||
|
case closing
|
||||||
|
case resizingUser
|
||||||
|
case resizingPreset
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Target Runtime Objects
|
||||||
|
|
||||||
|
### `AppController`
|
||||||
|
|
||||||
|
Top-level coordinator created by `AppDelegate`.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- boot app services
|
||||||
|
- respond to lifecycle events
|
||||||
|
- connect hotkey intents to workspace/screen actions
|
||||||
|
- own references to main long-lived services
|
||||||
|
|
||||||
|
### `WorkspaceRegistry`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- create workspace
|
||||||
|
- delete workspace
|
||||||
|
- rename workspace
|
||||||
|
- fetch workspace by id
|
||||||
|
- publish workspace list changes
|
||||||
|
- ensure at least one workspace exists
|
||||||
|
|
||||||
|
Suggested API:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@MainActor
|
||||||
|
protocol WorkspaceRegistryType: AnyObject {
|
||||||
|
var workspaceSummariesPublisher: AnyPublisher<[WorkspaceSummary], Never> { get }
|
||||||
|
|
||||||
|
func allWorkspaceSummaries() -> [WorkspaceSummary]
|
||||||
|
func workspaceController(for id: WorkspaceID) -> WorkspaceControllerType?
|
||||||
|
func createWorkspace(named: String?) -> WorkspaceID
|
||||||
|
func deleteWorkspace(id: WorkspaceID)
|
||||||
|
func renameWorkspace(id: WorkspaceID, name: String)
|
||||||
|
func ensureWorkspaceExists() -> WorkspaceID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WorkspaceController`
|
||||||
|
|
||||||
|
Replaces most of `TerminalManager`.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- own tabs for a single workspace
|
||||||
|
- create and close tabs
|
||||||
|
- detach tabs
|
||||||
|
- switch active tab
|
||||||
|
- update session appearance when settings change
|
||||||
|
- publish workspace state
|
||||||
|
|
||||||
|
Suggested API:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@MainActor
|
||||||
|
protocol WorkspaceControllerType: ObservableObject {
|
||||||
|
var state: WorkspaceState { get }
|
||||||
|
|
||||||
|
func newTab()
|
||||||
|
func closeTab(id: TabID)
|
||||||
|
func closeActiveTab()
|
||||||
|
func switchToTab(id: TabID)
|
||||||
|
func switchToTab(index: Int)
|
||||||
|
func nextTab()
|
||||||
|
func previousTab()
|
||||||
|
func detachActiveTab() -> TerminalSessionType?
|
||||||
|
func updateTheme(_ theme: TerminalTheme)
|
||||||
|
func updateFontSize(_ size: CGFloat)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ScreenRegistry`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- discover connected screens
|
||||||
|
- maintain `ScreenContext` for each screen
|
||||||
|
- maintain screen-to-workspace assignment
|
||||||
|
- rebuild state on screen changes
|
||||||
|
|
||||||
|
Suggested API:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@MainActor
|
||||||
|
protocol ScreenRegistryType: AnyObject {
|
||||||
|
var screenContextsPublisher: AnyPublisher<[ScreenContext], Never> { get }
|
||||||
|
|
||||||
|
func allScreens() -> [ScreenContext]
|
||||||
|
func screenContext(for id: ScreenID) -> ScreenContext?
|
||||||
|
func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID)
|
||||||
|
func activeScreenID() -> ScreenID?
|
||||||
|
func refreshConnectedScreens()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ScreenContext`
|
||||||
|
|
||||||
|
Observable object for one physical display.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- store local UI state
|
||||||
|
- expose a current `workspaceID`
|
||||||
|
- emit user intents for local notch behavior
|
||||||
|
|
||||||
|
This should replace today’s overloaded `NotchViewModel`.
|
||||||
|
|
||||||
|
### `NotchOrchestrator`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- open/close notch for a screen
|
||||||
|
- coordinate focus, hover, suppression, and transitions
|
||||||
|
- enforce transition rules
|
||||||
|
- drive frame updates indirectly through window coordination
|
||||||
|
|
||||||
|
The orchestrator should own the state machine, not `ContentView`.
|
||||||
|
|
||||||
|
### `WindowCoordinator`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- create/destroy one `NotchWindow` per screen
|
||||||
|
- bind `ScreenContext` to window content
|
||||||
|
- update window frames
|
||||||
|
- respond to `resignKey`
|
||||||
|
- focus the correct terminal view
|
||||||
|
|
||||||
|
This keeps AppKit-specific code out of registry/controller classes.
|
||||||
|
|
||||||
|
### `HotkeyService`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- register global hotkey bindings
|
||||||
|
- observe only hotkey-related settings changes
|
||||||
|
- emit typed commands
|
||||||
|
|
||||||
|
Suggested intent enum:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum AppCommand {
|
||||||
|
case toggleNotch(screenID: ScreenID?)
|
||||||
|
case newTab(workspaceID: WorkspaceID)
|
||||||
|
case closeTab(workspaceID: WorkspaceID)
|
||||||
|
case nextTab(workspaceID: WorkspaceID)
|
||||||
|
case previousTab(workspaceID: WorkspaceID)
|
||||||
|
case switchToTab(workspaceID: WorkspaceID, index: Int)
|
||||||
|
case detachTab(workspaceID: WorkspaceID)
|
||||||
|
case applySizePreset(screenID: ScreenID, presetID: UUID)
|
||||||
|
case switchWorkspace(screenID: ScreenID, workspaceID: WorkspaceID)
|
||||||
|
case createWorkspace(screenID: ScreenID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Terminal Session Design
|
||||||
|
|
||||||
|
### Current Problem
|
||||||
|
|
||||||
|
`TerminalSession` mixes:
|
||||||
|
|
||||||
|
- process lifecycle
|
||||||
|
- SwiftTerm view ownership
|
||||||
|
- delegate callbacks
|
||||||
|
- some UI-facing published state
|
||||||
|
|
||||||
|
This makes reuse difficult.
|
||||||
|
|
||||||
|
### Target Split
|
||||||
|
|
||||||
|
Introduce:
|
||||||
|
|
||||||
|
- `TerminalSession`
|
||||||
|
Process + session metadata + delegate event translation
|
||||||
|
- `TerminalViewHost`
|
||||||
|
Owns a `TerminalView` for one active screen/window
|
||||||
|
|
||||||
|
Minimal first-step compromise:
|
||||||
|
|
||||||
|
- Keep `TerminalSession` owning one `TerminalView`
|
||||||
|
- Enforce that only one screen at a time can actively render a given workspace
|
||||||
|
- Make that rule explicit in the architecture
|
||||||
|
|
||||||
|
Recommended v1 rule:
|
||||||
|
|
||||||
|
- many screens may point at the same workspace
|
||||||
|
- only one screen may have that workspace open and focused at a time
|
||||||
|
|
||||||
|
This avoids trying to mirror one live terminal view into multiple windows.
|
||||||
|
|
||||||
|
## Settings Design
|
||||||
|
|
||||||
|
### Current Problem
|
||||||
|
|
||||||
|
`@AppStorage` is scattered across many views and managers, and `UserDefaults` changes trigger broad runtime work.
|
||||||
|
|
||||||
|
### Target Design
|
||||||
|
|
||||||
|
Introduce:
|
||||||
|
|
||||||
|
- `AppSettingsStore`
|
||||||
|
Persistence boundary
|
||||||
|
- `AppSettingsController`
|
||||||
|
In-memory observable runtime settings
|
||||||
|
|
||||||
|
Suggested pattern:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
protocol AppSettingsStoreType {
|
||||||
|
func load() -> AppSettings
|
||||||
|
func save(_ settings: AppSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppSettingsController: ObservableObject {
|
||||||
|
@Published private(set) var settings: AppSettings
|
||||||
|
|
||||||
|
func update(_ update: (inout AppSettings) -> Void)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Views bind to `AppSettingsController`.
|
||||||
|
Services subscribe to precise sub-slices of settings.
|
||||||
|
|
||||||
|
## Persistence Design
|
||||||
|
|
||||||
|
Persist only durable data:
|
||||||
|
|
||||||
|
- app settings
|
||||||
|
- workspace summaries
|
||||||
|
- screen-to-workspace assignments
|
||||||
|
- optional workspace-local preferences
|
||||||
|
- size presets
|
||||||
|
|
||||||
|
Do not persist:
|
||||||
|
|
||||||
|
- live `LocalProcess`
|
||||||
|
- active shell buffer contents
|
||||||
|
- transient hover/focus/transition state
|
||||||
|
|
||||||
|
Suggested storage keys:
|
||||||
|
|
||||||
|
- `appSettings`
|
||||||
|
- `workspaceSummaries`
|
||||||
|
- `screenAssignments`
|
||||||
|
- `terminalSizePresets`
|
||||||
|
|
||||||
|
Prefer a single encoded settings object per concern over many independent keys.
|
||||||
|
|
||||||
|
## UI Structure
|
||||||
|
|
||||||
|
### Root Views
|
||||||
|
|
||||||
|
- `NotchRootView`
|
||||||
|
- `ClosedNotchView`
|
||||||
|
- `OpenWorkspaceView`
|
||||||
|
- `TabStripView`
|
||||||
|
- `WorkspacePickerView`
|
||||||
|
- `SettingsRootView`
|
||||||
|
|
||||||
|
### View Rules
|
||||||
|
|
||||||
|
- Views may read state from observable objects.
|
||||||
|
- Views may emit intents via closures or controller methods.
|
||||||
|
- Views do not call global singletons.
|
||||||
|
- Views do not own delayed business logic unless it is purely visual.
|
||||||
|
|
||||||
|
## Workspace UX Rules
|
||||||
|
|
||||||
|
- Every screen always has an assigned workspace.
|
||||||
|
- A new screen defaults to:
|
||||||
|
- the app default workspace strategy, or
|
||||||
|
- the first existing workspace
|
||||||
|
- Users can:
|
||||||
|
- switch a screen to another workspace
|
||||||
|
- create a new workspace from a screen
|
||||||
|
- rename a workspace
|
||||||
|
- Deleting a workspace:
|
||||||
|
- reassigns affected screens to a fallback workspace
|
||||||
|
- is disallowed if it is the last workspace
|
||||||
|
|
||||||
|
## Screen UX Rules
|
||||||
|
|
||||||
|
- A screen can be open/closed independently of other screens.
|
||||||
|
- Two screens may point at the same workspace.
|
||||||
|
- If a workspace is already open on another screen:
|
||||||
|
- opening it on this screen should either transfer focus, or
|
||||||
|
- close it on the other screen first
|
||||||
|
|
||||||
|
Recommended v1 behavior:
|
||||||
|
|
||||||
|
- one active open screen per workspace
|
||||||
|
- opening workspace `W` on screen `B` closes `W` on screen `A`
|
||||||
|
|
||||||
|
That is simple, deterministic, and compatible with single `TerminalView` ownership.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### Phase 0: Hygiene
|
||||||
|
|
||||||
|
- remove dead settings and unused properties
|
||||||
|
- fix version drift
|
||||||
|
- fix stale comments
|
||||||
|
- add test targets
|
||||||
|
- isolate root build artifacts from source tree if desired
|
||||||
|
|
||||||
|
### Phase 1: Settings Layer
|
||||||
|
|
||||||
|
- create `AppSettings`, `AppSettingsStore`, `AppSettingsController`
|
||||||
|
- move scattered `@AppStorage` usage into typed settings reads/writes
|
||||||
|
- make `HotkeyService` observe only hotkey settings
|
||||||
|
- make window sizing observe only relevant settings
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- no runtime logic depends on `UserDefaults.didChangeNotification`
|
||||||
|
|
||||||
|
### Phase 2: Workspace Core
|
||||||
|
|
||||||
|
- add `WorkspaceSummary`, `WorkspaceRegistry`, `WorkspaceController`
|
||||||
|
- migrate current `TerminalManager` logic into `WorkspaceController`
|
||||||
|
- ensure one default workspace exists
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- tabs exist inside a workspace object, not globally
|
||||||
|
|
||||||
|
### Phase 3: Screen Core
|
||||||
|
|
||||||
|
- add `ScreenContext`, `ScreenRegistry`
|
||||||
|
- migrate current `NotchViewModel` responsibilities into `ScreenContext`
|
||||||
|
- persist screen-to-workspace assignments
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- screen-local state is independent from workspace state
|
||||||
|
|
||||||
|
### Phase 4: Notch Lifecycle Orchestrator
|
||||||
|
|
||||||
|
- move hover/open/close/transition logic out of `ContentView`
|
||||||
|
- add explicit transition states
|
||||||
|
- centralize focus/open/close sequencing
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- `ContentView` becomes mostly rendering + view intents
|
||||||
|
|
||||||
|
### Phase 5: Window Coordination
|
||||||
|
|
||||||
|
- extract AppKit window creation and frame management into `WindowCoordinator`
|
||||||
|
- keep `ScreenManager` as thin glue or replace it entirely
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- no window-specific behavior in workspace logic
|
||||||
|
|
||||||
|
### Phase 6: Workspace Switching UX
|
||||||
|
|
||||||
|
- add workspace picker UI
|
||||||
|
- support create/switch/rename/delete workspaces
|
||||||
|
- enforce one-open-screen-per-workspace behavior
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- a user can assign the same workspace to multiple screens
|
||||||
|
|
||||||
|
### Phase 7: Popout and Session Cleanup
|
||||||
|
|
||||||
|
- formalize detached tab ownership
|
||||||
|
- remove session observer leaks
|
||||||
|
- remove app-global current-directory mutation
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- session lifetime is explicit and testable
|
||||||
|
|
||||||
|
### Phase 8: Test Expansion
|
||||||
|
|
||||||
|
- add unit coverage for all core models/controllers
|
||||||
|
- add integration tests for orchestrators
|
||||||
|
- add XCUITests for key user flows
|
||||||
|
|
||||||
|
## Proposed File Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
CommandNotch/CommandNotch/
|
||||||
|
App/
|
||||||
|
CommandNotchApp.swift
|
||||||
|
AppDelegate.swift
|
||||||
|
AppController.swift
|
||||||
|
|
||||||
|
Core/
|
||||||
|
Settings/
|
||||||
|
AppSettings.swift
|
||||||
|
AppSettingsController.swift
|
||||||
|
AppSettingsStore.swift
|
||||||
|
UserDefaultsAppSettingsStore.swift
|
||||||
|
Workspaces/
|
||||||
|
WorkspaceSummary.swift
|
||||||
|
WorkspaceState.swift
|
||||||
|
WorkspaceRegistry.swift
|
||||||
|
WorkspaceController.swift
|
||||||
|
WorkspaceStore.swift
|
||||||
|
Screens/
|
||||||
|
ScreenContext.swift
|
||||||
|
ScreenRegistry.swift
|
||||||
|
ScreenAssignmentStore.swift
|
||||||
|
Notch/
|
||||||
|
NotchOrchestrator.swift
|
||||||
|
NotchStateMachine.swift
|
||||||
|
NotchFrameCalculator.swift
|
||||||
|
Terminal/
|
||||||
|
TerminalSession.swift
|
||||||
|
TerminalSessionFactory.swift
|
||||||
|
TerminalTheme.swift
|
||||||
|
TerminalTabState.swift
|
||||||
|
Hotkeys/
|
||||||
|
HotkeyService.swift
|
||||||
|
HotkeyBinding.swift
|
||||||
|
AppCommand.swift
|
||||||
|
|
||||||
|
UI/
|
||||||
|
Notch/
|
||||||
|
NotchRootView.swift
|
||||||
|
ClosedNotchView.swift
|
||||||
|
OpenWorkspaceView.swift
|
||||||
|
TabStripView.swift
|
||||||
|
WorkspacePickerView.swift
|
||||||
|
Settings/
|
||||||
|
SettingsRootView.swift
|
||||||
|
GeneralSettingsView.swift
|
||||||
|
AppearanceSettingsView.swift
|
||||||
|
AnimationSettingsView.swift
|
||||||
|
TerminalSettingsView.swift
|
||||||
|
HotkeySettingsView.swift
|
||||||
|
WorkspaceSettingsView.swift
|
||||||
|
Components/
|
||||||
|
SwiftTermView.swift
|
||||||
|
HotkeyRecorderView.swift
|
||||||
|
NotchShape.swift
|
||||||
|
|
||||||
|
AppKit/
|
||||||
|
WindowCoordinator.swift
|
||||||
|
NotchWindow.swift
|
||||||
|
SettingsCoordinator.swift
|
||||||
|
PopoutCoordinator.swift
|
||||||
|
ScreenProvider.swift
|
||||||
|
|
||||||
|
Persistence/
|
||||||
|
CodableStore.swift
|
||||||
|
|
||||||
|
Extensions/
|
||||||
|
NSScreen+Extensions.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Boundaries Required For Tests
|
||||||
|
|
||||||
|
Create protocols around:
|
||||||
|
|
||||||
|
- settings store
|
||||||
|
- workspace store
|
||||||
|
- screen provider
|
||||||
|
- window factory
|
||||||
|
- hotkey registrar
|
||||||
|
- launch-at-login service
|
||||||
|
- terminal session factory
|
||||||
|
- clock/scheduler for delayed hover/open/close logic
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
protocol ScreenProviderType {
|
||||||
|
var screens: [ScreenDescriptor] { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol WindowCoordinatorType {
|
||||||
|
func showWindow(for screenID: ScreenID)
|
||||||
|
func hideWindow(for screenID: ScreenID)
|
||||||
|
func updateFrame(for screenID: ScreenID, frame: CGRect)
|
||||||
|
func focusTerminal(for screenID: ScreenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol SchedulerType {
|
||||||
|
func schedule(after interval: TimeInterval, _ action: @escaping () -> Void) -> Cancellable
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
## 1. Unit Tests
|
||||||
|
|
||||||
|
Add a `CommandNotchTests` target.
|
||||||
|
|
||||||
|
Focus on pure logic:
|
||||||
|
|
||||||
|
- `WorkspaceRegistryTests`
|
||||||
|
- creates default workspace
|
||||||
|
- cannot delete last workspace
|
||||||
|
- rename/delete/create behavior
|
||||||
|
- `WorkspaceControllerTests`
|
||||||
|
- new tab
|
||||||
|
- close active tab
|
||||||
|
- detach active tab
|
||||||
|
- active tab index/id updates correctly
|
||||||
|
- `ScreenRegistryTests`
|
||||||
|
- new screen gets valid assignment
|
||||||
|
- assignment changes persist
|
||||||
|
- missing workspace reassigns to fallback
|
||||||
|
- `NotchStateMachineTests`
|
||||||
|
- closed -> opening -> open
|
||||||
|
- close suppression while hovering
|
||||||
|
- resizing transitions
|
||||||
|
- `NotchFrameCalculatorTests`
|
||||||
|
- clamping
|
||||||
|
- per-screen max bounds
|
||||||
|
- closed-notch size behavior
|
||||||
|
- `HotkeyBindingTests`
|
||||||
|
- event matching
|
||||||
|
- preset digit mapping
|
||||||
|
- `AppSettingsControllerTests`
|
||||||
|
- only targeted changes publish
|
||||||
|
|
||||||
|
## 2. Integration Tests
|
||||||
|
|
||||||
|
Use fakes for AppKit boundaries.
|
||||||
|
|
||||||
|
- `NotchOrchestratorIntegrationTests`
|
||||||
|
- opening a workspace on one screen closes it on another screen if shared
|
||||||
|
- focus transfer rules
|
||||||
|
- preset resize behavior
|
||||||
|
- `AppControllerIntegrationTests`
|
||||||
|
- hotkey command routes to active screen/workspace
|
||||||
|
- settings changes update workspaces without broad side effects
|
||||||
|
|
||||||
|
## 3. UI Tests
|
||||||
|
|
||||||
|
Add a `CommandNotchUITests` target with XCUITest.
|
||||||
|
|
||||||
|
Recommended initial flows:
|
||||||
|
|
||||||
|
- launch app and open settings
|
||||||
|
- create workspace from settings or notch UI
|
||||||
|
- assign workspace to another screen context if testable
|
||||||
|
- create tab
|
||||||
|
- switch tabs
|
||||||
|
- apply size preset
|
||||||
|
- detach active tab
|
||||||
|
|
||||||
|
UI tests should validate behavior and existence, not exact pixel values.
|
||||||
|
|
||||||
|
## 4. Optional Snapshot Tests
|
||||||
|
|
||||||
|
Only add after state is injectable.
|
||||||
|
|
||||||
|
Snapshot candidates:
|
||||||
|
|
||||||
|
- closed notch
|
||||||
|
- open notch with one tab
|
||||||
|
- workspace picker
|
||||||
|
- settings panes
|
||||||
|
|
||||||
|
Do not start here.
|
||||||
|
|
||||||
|
## Testing SwiftUI/AppKit UI
|
||||||
|
|
||||||
|
### What to test directly
|
||||||
|
|
||||||
|
- view model/controller behavior
|
||||||
|
- user intents emitted by the view
|
||||||
|
- XCUITest end-to-end flows
|
||||||
|
|
||||||
|
### What not to over-invest in early
|
||||||
|
|
||||||
|
- exact animation curves
|
||||||
|
- fragile layout pixel assertions
|
||||||
|
- direct testing of `NSWindow` internals unless wrapped behind protocols
|
||||||
|
|
||||||
|
### Practical answer
|
||||||
|
|
||||||
|
For this app, “testing UI” mostly means testing the logic that drives the UI, plus a thin XCUITest layer that proves the main flows work.
|
||||||
|
|
||||||
|
## Current Issues Mapped To Fixes
|
||||||
|
|
||||||
|
- Global singleton tab state
|
||||||
|
Fix with `WorkspaceRegistry` + `WorkspaceController`
|
||||||
|
- `UserDefaults` as runtime bus
|
||||||
|
Fix with typed settings controller and targeted subscriptions
|
||||||
|
- Dead gesture settings
|
||||||
|
Remove or implement later behind a dedicated feature
|
||||||
|
- Observer leakage in tab creation
|
||||||
|
Store cancellables per session or derive titles from workspace state
|
||||||
|
- App-global cwd mutation
|
||||||
|
Remove from session startup; set process cwd explicitly if supported
|
||||||
|
- Monolithic settings file
|
||||||
|
Split into feature files bound to typed settings
|
||||||
|
- Timing-based UI behavior spread across layers
|
||||||
|
Centralize in `NotchOrchestrator`
|
||||||
|
- No tests
|
||||||
|
Add unit + integration + XCUITest targets
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
- [ ] Add `CommandNotchTests`
|
||||||
|
- [ ] Add `CommandNotchUITests`
|
||||||
|
- [ ] Create `AppSettings` model
|
||||||
|
- [ ] Create `AppSettingsStore`
|
||||||
|
- [ ] Replace broad defaults observation
|
||||||
|
- [ ] Create `WorkspaceRegistry`
|
||||||
|
- [ ] Create `WorkspaceController`
|
||||||
|
- [ ] Move tab logic out of `TerminalManager`
|
||||||
|
- [ ] Create `ScreenContext`
|
||||||
|
- [ ] Create `ScreenRegistry`
|
||||||
|
- [ ] Persist screen assignments
|
||||||
|
- [ ] Create `NotchOrchestrator`
|
||||||
|
- [ ] Create `NotchFrameCalculator`
|
||||||
|
- [ ] Extract `WindowCoordinator`
|
||||||
|
- [ ] Add workspace picker UI
|
||||||
|
- [ ] Add create/switch/rename/delete workspace flows
|
||||||
|
- [ ] Enforce one-open-screen-per-workspace
|
||||||
|
- [ ] Clean up detached session ownership
|
||||||
|
- [ ] Split settings views into separate files
|
||||||
|
- [ ] Remove dead settings and unused code
|
||||||
|
- [ ] Add unit tests for registry/controller/state machine/frame calculator
|
||||||
|
- [ ] Add initial XCUITest flows
|
||||||
|
|
||||||
|
## First Concrete Refactor Slice
|
||||||
|
|
||||||
|
If implementing incrementally, the best first slice is:
|
||||||
|
|
||||||
|
1. add tests target
|
||||||
|
2. add `AppSettings`
|
||||||
|
3. add `WorkspaceRegistry` with a single default workspace
|
||||||
|
4. migrate current `TerminalManager` into a first `WorkspaceController`
|
||||||
|
5. change the app so all current behavior still uses one workspace
|
||||||
|
|
||||||
|
That gives immediate architectural improvement without changing UX yet.
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
|
||||||
|
The refactor is successful when:
|
||||||
|
|
||||||
|
- there is no critical runtime logic driven by broad `UserDefaults` notifications
|
||||||
|
- tabs/sessions live under workspaces, not globally
|
||||||
|
- screens are explicitly assigned to workspaces
|
||||||
|
- the same workspace can be selected on multiple screens
|
||||||
|
- only one open/focused screen owns a workspace’s live terminal at a time
|
||||||
|
- window behavior is separated from domain logic
|
||||||
|
- unit tests cover the core state machines and registries
|
||||||
|
- XCUITests cover main user flows
|
||||||
|
|
||||||
351
docs/workspace-split-pane-plan.md
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
# Workspace Split Pane Plan
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Capture the agreed design for workspace-owned split terminals so the feature can be implemented later without redoing scope and architecture decisions.
|
||||||
|
|
||||||
|
## Agreed Scope
|
||||||
|
|
||||||
|
- Split layouts belong to the workspace, not the screen.
|
||||||
|
- A workspace may only be actively presented on one screen at a time.
|
||||||
|
- Split layouts should be visible the same way when that workspace is opened on another monitor.
|
||||||
|
- Nested splits are supported.
|
||||||
|
- Any two tabs may be joined into a split.
|
||||||
|
- Closing a pane causes its sibling to expand.
|
||||||
|
- Split layout must survive relaunch.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Simultaneous live rendering of the same workspace on multiple screens.
|
||||||
|
- Full multi-user or collaborative state sync.
|
||||||
|
- Arbitrary freeform pane layout. The model is tree-based binary splits.
|
||||||
|
- Solving all interaction polish in the first pass.
|
||||||
|
|
||||||
|
## Current Constraint
|
||||||
|
|
||||||
|
The current workspace model is flat:
|
||||||
|
|
||||||
|
- `WorkspaceController` owns `[TerminalSession]`
|
||||||
|
- one tab is active at a time
|
||||||
|
- `ContentView` renders exactly one active `SwiftTermView`
|
||||||
|
|
||||||
|
This means split panes are not an additive UI feature. They require a workspace model refactor.
|
||||||
|
|
||||||
|
## Recommended Model
|
||||||
|
|
||||||
|
Do not model splits as "adjacent tabs with special behavior" internally.
|
||||||
|
|
||||||
|
Instead:
|
||||||
|
|
||||||
|
1. A workspace owns ordered top-level tabs.
|
||||||
|
2. Each top-level tab owns a pane tree.
|
||||||
|
3. Pane tree leaves own `TerminalSession` instances.
|
||||||
|
4. Pane tree internal nodes represent binary splits.
|
||||||
|
5. The tab bar can visually present grouped/joined tabs based on the active tab's pane composition.
|
||||||
|
|
||||||
|
This keeps the domain model clean while still allowing the desired visual metaphor.
|
||||||
|
|
||||||
|
## Proposed Domain Types
|
||||||
|
|
||||||
|
```swift
|
||||||
|
typealias WorkspaceTabID = UUID
|
||||||
|
typealias PaneID = UUID
|
||||||
|
|
||||||
|
struct WorkspaceTab: Identifiable, Equatable, Codable {
|
||||||
|
var id: WorkspaceTabID
|
||||||
|
var rootPane: PaneNode
|
||||||
|
var selectedPaneID: PaneID?
|
||||||
|
var titleMode: WorkspaceTabTitleMode
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaneNode: Equatable, Codable, Identifiable {
|
||||||
|
case leaf(PaneLeaf)
|
||||||
|
case split(PaneSplit)
|
||||||
|
|
||||||
|
var id: PaneID { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PaneLeaf: Equatable, Codable, Identifiable {
|
||||||
|
var id: PaneID
|
||||||
|
var sessionID: UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PaneSplit: Equatable, Codable, Identifiable {
|
||||||
|
var id: PaneID
|
||||||
|
var axis: SplitAxis
|
||||||
|
var ratio: Double
|
||||||
|
var first: PaneNode
|
||||||
|
var second: PaneNode
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SplitAxis: String, Codable {
|
||||||
|
case horizontal
|
||||||
|
case vertical
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `ratio` should be persisted so user-adjusted divider positions survive relaunch.
|
||||||
|
- `selectedPaneID` allows focus-based commands such as split active pane, close active pane, and move focus.
|
||||||
|
- The persisted model should store session identity separately from runtime AppKit/SwiftTerm objects.
|
||||||
|
|
||||||
|
## Runtime Ownership
|
||||||
|
|
||||||
|
Global:
|
||||||
|
|
||||||
|
- workspace summaries
|
||||||
|
- workspace ordering
|
||||||
|
- workspace-to-screen assignment
|
||||||
|
- hotkeys
|
||||||
|
|
||||||
|
Workspace:
|
||||||
|
|
||||||
|
- ordered top-level tabs
|
||||||
|
- active top-level tab
|
||||||
|
- per-tab pane tree
|
||||||
|
- active/selected pane
|
||||||
|
- terminal sessions for pane leaves
|
||||||
|
|
||||||
|
Screen:
|
||||||
|
|
||||||
|
- which workspace is assigned
|
||||||
|
- notch open/close state
|
||||||
|
- geometry and transitions
|
||||||
|
|
||||||
|
This remains aligned with the existing workspace architecture.
|
||||||
|
|
||||||
|
## Persistence Strategy
|
||||||
|
|
||||||
|
Split layout must survive relaunch, so workspace persistence needs to grow beyond summary metadata.
|
||||||
|
|
||||||
|
Recommended approach:
|
||||||
|
|
||||||
|
1. Keep `WorkspaceSummary` for list metadata only.
|
||||||
|
2. Introduce a persisted `WorkspaceDocument` or `WorkspaceSnapshot` per workspace.
|
||||||
|
3. Persist:
|
||||||
|
- top-level tabs
|
||||||
|
- pane tree
|
||||||
|
- active tab ID
|
||||||
|
- selected pane ID
|
||||||
|
- divider ratios
|
||||||
|
4. Do not attempt to persist shell process contents.
|
||||||
|
5. On relaunch, recreate sessions for pane leaves and restore layout structure only.
|
||||||
|
|
||||||
|
Important distinction:
|
||||||
|
|
||||||
|
- layout persistence is required
|
||||||
|
- terminal process continuity is not
|
||||||
|
|
||||||
|
## UI Model
|
||||||
|
|
||||||
|
### Main Content
|
||||||
|
|
||||||
|
The active top-level tab renders as a recursive split tree.
|
||||||
|
|
||||||
|
- leaf node -> one `SwiftTermView`
|
||||||
|
- split node -> `HSplitView`/`VSplitView` equivalent SwiftUI container with draggable divider
|
||||||
|
|
||||||
|
### Tab Bar
|
||||||
|
|
||||||
|
Top-level tabs remain the primary navigation unit.
|
||||||
|
|
||||||
|
Visual behavior:
|
||||||
|
|
||||||
|
- a normal unsplit tab looks like today
|
||||||
|
- a joined/split tab should look grouped
|
||||||
|
- grouped tabs should expose child pane titles visually inside the tab item
|
||||||
|
|
||||||
|
Recommended first-pass appearance:
|
||||||
|
|
||||||
|
- one outer tab pill per top-level tab
|
||||||
|
- inside that pill, show compact child title chips for each leaf pane
|
||||||
|
- highlight the selected pane's chip
|
||||||
|
|
||||||
|
This gives the "tabs are joined" feel without making pane leaves first-class top-level tabs in the data model.
|
||||||
|
|
||||||
|
## Command Model
|
||||||
|
|
||||||
|
New actions likely needed:
|
||||||
|
|
||||||
|
- split active pane horizontally
|
||||||
|
- split active pane vertically
|
||||||
|
- focus next pane
|
||||||
|
- focus previous pane
|
||||||
|
- close active pane
|
||||||
|
- resize focused split divider
|
||||||
|
- join tab A into tab B
|
||||||
|
- detach pane into new top-level tab
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
|
||||||
|
- joining any two tabs merges one tab's root pane into the other's tree
|
||||||
|
- the source top-level tab is removed
|
||||||
|
- the destination top-level tab remains
|
||||||
|
- sibling expansion on close is standard tree collapse
|
||||||
|
|
||||||
|
## Join Semantics
|
||||||
|
|
||||||
|
Joining any two tabs should work as:
|
||||||
|
|
||||||
|
1. Choose destination tab.
|
||||||
|
2. Choose source tab.
|
||||||
|
3. Replace destination root with a new split node:
|
||||||
|
- first child = old destination root
|
||||||
|
- second child = source root
|
||||||
|
4. Remove source tab from workspace order.
|
||||||
|
5. Select a predictable pane, preferably the source pane that was just added.
|
||||||
|
|
||||||
|
An explicit split axis should be required for the join action.
|
||||||
|
|
||||||
|
## Close Semantics
|
||||||
|
|
||||||
|
When closing a pane:
|
||||||
|
|
||||||
|
- if the pane has a sibling, the sibling expands into the parent's position
|
||||||
|
- if the pane was the only leaf in a top-level tab:
|
||||||
|
- close the whole top-level tab if more than one tab exists
|
||||||
|
- otherwise create a replacement shell pane, matching current single-tab safety behavior
|
||||||
|
|
||||||
|
## Focus Semantics
|
||||||
|
|
||||||
|
Pane focus must become explicit.
|
||||||
|
|
||||||
|
Recommended rules:
|
||||||
|
|
||||||
|
- mouse click focuses that pane
|
||||||
|
- splitting focuses the new pane
|
||||||
|
- joining focuses the moved-in pane
|
||||||
|
- closing a pane focuses the surviving sibling
|
||||||
|
- top-level tab switch restores the previously selected pane in that tab
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
Implement in stages to reduce risk.
|
||||||
|
|
||||||
|
### Stage 1: Domain Refactor
|
||||||
|
|
||||||
|
- Replace flat workspace tabs with top-level tab objects.
|
||||||
|
- Introduce pane tree types.
|
||||||
|
- Keep only one leaf per tab initially so behavior is unchanged.
|
||||||
|
|
||||||
|
### Stage 2: Runtime Layout Rendering
|
||||||
|
|
||||||
|
- Render pane trees recursively in `ContentView`.
|
||||||
|
- Add active pane selection.
|
||||||
|
- Keep persistence off until runtime behavior is stable.
|
||||||
|
|
||||||
|
### Stage 3: Split Actions
|
||||||
|
|
||||||
|
- Split active pane horizontally/vertically.
|
||||||
|
- Close pane with sibling expansion.
|
||||||
|
- Basic focus movement.
|
||||||
|
|
||||||
|
### Stage 4: Joined Tab UI
|
||||||
|
|
||||||
|
- Update tab bar to show grouped child pane chips.
|
||||||
|
- Surface active pane clearly.
|
||||||
|
|
||||||
|
### Stage 5: Join / Unjoin Flows
|
||||||
|
|
||||||
|
- Join any two tabs with explicit axis choice.
|
||||||
|
- Support promoting a pane back to its own top-level tab if needed.
|
||||||
|
|
||||||
|
### Stage 6: Persistence
|
||||||
|
|
||||||
|
- Persist pane trees and top-level tab state.
|
||||||
|
- Recreate sessions on launch.
|
||||||
|
|
||||||
|
### Stage 7: Hotkeys and Polish
|
||||||
|
|
||||||
|
- Add pane-focused shortcuts.
|
||||||
|
- Add divider dragging polish.
|
||||||
|
- Improve visual grouped-tab affordances.
|
||||||
|
|
||||||
|
## Main Risks
|
||||||
|
|
||||||
|
### 1. Model complexity
|
||||||
|
|
||||||
|
The current flat `[TerminalSession]` model is simple. Tree-based layout introduces more state, more edge cases, and more focus semantics.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- refactor the data model before touching complex UI
|
||||||
|
- keep top-level tabs and pane leaves distinct
|
||||||
|
|
||||||
|
### 2. SwiftTerm view ownership
|
||||||
|
|
||||||
|
`TerminalView` cannot be mounted in multiple places safely.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- preserve current rule: only one active presenting screen per workspace
|
||||||
|
- keep one runtime `TerminalSession` per pane leaf
|
||||||
|
|
||||||
|
### 3. Persistence mismatch
|
||||||
|
|
||||||
|
Persisting layout is easy compared with persisting process state.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- persist layout and selection only
|
||||||
|
- recreate shell sessions on launch
|
||||||
|
|
||||||
|
### 4. Joined-tab UX ambiguity
|
||||||
|
|
||||||
|
If grouped tabs are also used as pane chips, the interaction model can get confusing.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- preserve top-level tabs as the real navigation unit
|
||||||
|
- use internal chips only as secondary indicators/actions
|
||||||
|
|
||||||
|
## Recommended First Implementation Boundary
|
||||||
|
|
||||||
|
The first deliverable should include:
|
||||||
|
|
||||||
|
- nested binary split trees
|
||||||
|
- active pane focus
|
||||||
|
- split and close pane actions
|
||||||
|
- sibling expansion
|
||||||
|
- grouped tab appearance
|
||||||
|
- join any two tabs
|
||||||
|
- persisted layout across relaunch
|
||||||
|
- same workspace layout when moved to another screen
|
||||||
|
|
||||||
|
The first deliverable should not include:
|
||||||
|
|
||||||
|
- simultaneous same-workspace rendering on multiple screens
|
||||||
|
- drag-and-drop tree editing
|
||||||
|
- restoring running shell process contents
|
||||||
|
|
||||||
|
## Files Likely Impacted
|
||||||
|
|
||||||
|
- `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:
|
||||||
|
|
||||||
|
- pane tree domain types
|
||||||
|
- persisted workspace document types
|
||||||
|
- split-pane rendering view(s)
|
||||||
|
- pane-focused command helpers
|
||||||
|
|
||||||
|
## Decision Summary
|
||||||
|
|
||||||
|
The feature is feasible.
|
||||||
|
|
||||||
|
The correct architecture is:
|
||||||
|
|
||||||
|
- workspace-owned split tree
|
||||||
|
- top-level tabs remain the primary unit
|
||||||
|
- grouped tab visuals are a UI layer over the pane tree
|
||||||
|
- one presenting screen per workspace
|
||||||
|
- persisted layout, not persisted process contents
|
||||||
|
|
||||||
|
That is the implementation direction to use when this work is resumed.
|
||||||
BIN
icons/Downterm-icon-128.png
Normal file
|
After Width: | Height: | Size: 945 B |