mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-04 02:18:17 +00:00
Compare commits
12 Commits
7d5be8e960
...
befee07f16
Author | SHA1 | Date | |
---|---|---|---|
![]() |
befee07f16 | ||
![]() |
c8243ffd99 | ||
![]() |
084ff2de67 | ||
![]() |
e1f3f52686 | ||
![]() |
fe3dab9467 | ||
![]() |
b90c72aea6 | ||
![]() |
e6d60dee07 | ||
![]() |
508e36bc03 | ||
![]() |
6a9b8b70cc | ||
![]() |
1dee9e7cb2 | ||
![]() |
291d4ed423 | ||
![]() |
5eb69b405d |
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Agent Development Guide
|
||||
|
||||
A file for [guiding coding agents](https://agents.md/).
|
||||
|
||||
## Commands
|
||||
|
||||
- **Build:** `zig build`
|
||||
- **Test (Zig):** `zig build test`
|
||||
- **Test filter (Zig)**: `zig build test -Dtest-filter=<test name>`
|
||||
- **Formatting (Zig)**: `zig fmt .`
|
||||
- **Formatting (other)**: `prettier -w .`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- Shared Zig core: `src/`
|
||||
- C API: `include/ghostty.h`
|
||||
- macOS app: `macos/`
|
||||
- GTK (Linux and FreeBSD) app: `src/apprt/gtk-ng`
|
||||
|
||||
## macOS App
|
||||
|
||||
- Do not use `xcodebuild`
|
||||
- Use `zig build` to build the macOS app and any shared Zig code
|
@@ -937,7 +937,7 @@ class AppDelegate: NSObject,
|
||||
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
|
||||
for c in TerminalController.all {
|
||||
for view in c.surfaceTree {
|
||||
if view.uuid == uuid {
|
||||
if view.id == uuid {
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
@@ -34,7 +34,7 @@ struct TerminalEntity: AppEntity {
|
||||
/// Returns the view associated with this entity. This may no longer exist.
|
||||
@MainActor
|
||||
var surfaceView: Ghostty.SurfaceView? {
|
||||
Self.defaultQuery.all.first { $0.uuid == self.id }
|
||||
Self.defaultQuery.all.first { $0.id == self.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -46,7 +46,7 @@ struct TerminalEntity: AppEntity {
|
||||
|
||||
@MainActor
|
||||
init(_ view: Ghostty.SurfaceView) {
|
||||
self.id = view.uuid
|
||||
self.id = view.id
|
||||
self.title = view.title
|
||||
self.workingDirectory = view.pwd
|
||||
if let nsImage = ImageRenderer(content: view.screenshot()).nsImage {
|
||||
@@ -80,7 +80,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
|
||||
@MainActor
|
||||
func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] {
|
||||
return all.filter {
|
||||
identifiers.contains($0.uuid)
|
||||
identifiers.contains($0.id)
|
||||
}.map {
|
||||
TerminalEntity($0)
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
|
||||
/// SplitTree represents a tree of views that can be divided.
|
||||
struct SplitTree<ViewType: NSView & Codable>: Codable {
|
||||
struct SplitTree<ViewType: NSView & Codable & Identifiable> {
|
||||
/// The root of the tree. This can be nil to indicate the tree is empty.
|
||||
let root: Node?
|
||||
|
||||
@@ -29,12 +29,12 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
|
||||
}
|
||||
|
||||
/// The path to a specific node in the tree.
|
||||
struct Path {
|
||||
struct Path: Codable {
|
||||
let path: [Component]
|
||||
|
||||
var isEmpty: Bool { path.isEmpty }
|
||||
|
||||
enum Component {
|
||||
enum Component: Codable {
|
||||
case left
|
||||
case right
|
||||
}
|
||||
@@ -53,7 +53,7 @@ struct SplitTree<ViewType: NSView & Codable>: Codable {
|
||||
let node: Node
|
||||
let bounds: CGRect
|
||||
}
|
||||
|
||||
|
||||
/// Direction for spatial navigation within the split tree.
|
||||
enum Direction {
|
||||
case left
|
||||
@@ -127,44 +127,51 @@ extension SplitTree {
|
||||
root: try root.insert(view: view, at: at, direction: direction),
|
||||
zoomed: nil)
|
||||
}
|
||||
/// Find a node containing a view with the specified ID.
|
||||
/// - Parameter id: The ID of the view to find
|
||||
/// - Returns: The node containing the view if found, nil otherwise
|
||||
func find(id: ViewType.ID) -> Node? {
|
||||
guard let root else { return nil }
|
||||
return root.find(id: id)
|
||||
}
|
||||
|
||||
/// Remove a node from the tree. If the node being removed is part of a split,
|
||||
/// the sibling node takes the place of the parent split.
|
||||
func remove(_ target: Node) -> Self {
|
||||
guard let root else { return self }
|
||||
|
||||
|
||||
// If we're removing the root itself, return an empty tree
|
||||
if root == target {
|
||||
return .init(root: nil, zoomed: nil)
|
||||
}
|
||||
|
||||
|
||||
// Otherwise, try to remove from the tree
|
||||
let newRoot = root.remove(target)
|
||||
|
||||
|
||||
// Update zoomed if it was the removed node
|
||||
let newZoomed = (zoomed == target) ? nil : zoomed
|
||||
|
||||
|
||||
return .init(root: newRoot, zoomed: newZoomed)
|
||||
}
|
||||
|
||||
/// Replace a node in the tree with a new node.
|
||||
func replace(node: Node, with newNode: Node) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
|
||||
|
||||
// Get the path to the node we want to replace
|
||||
guard let path = root.path(to: node) else {
|
||||
throw SplitError.viewNotFound
|
||||
}
|
||||
|
||||
|
||||
// Replace the node
|
||||
let newRoot = try root.replaceNode(at: path, with: newNode)
|
||||
|
||||
|
||||
// Update zoomed if it was the replaced node
|
||||
let newZoomed = (zoomed == node) ? newNode : zoomed
|
||||
|
||||
|
||||
return .init(root: newRoot, zoomed: newZoomed)
|
||||
}
|
||||
|
||||
|
||||
/// Find the next view to focus based on the current focused node and direction
|
||||
func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
|
||||
guard let root else { return nil }
|
||||
@@ -230,13 +237,13 @@ extension SplitTree {
|
||||
let newRoot = root.equalize()
|
||||
return .init(root: newRoot, zoomed: zoomed)
|
||||
}
|
||||
|
||||
|
||||
/// Resize a node in the tree by the given pixel amount in the specified direction.
|
||||
///
|
||||
///
|
||||
/// This method adjusts the split ratios of the tree to accommodate the requested resize
|
||||
/// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts
|
||||
/// its ratio. For left/right resizing, it finds the nearest parent horizontal split.
|
||||
/// The bounds parameter is used to construct the spatial tree representation which is
|
||||
/// The bounds parameter is used to construct the spatial tree representation which is
|
||||
/// needed to calculate the current pixel dimensions.
|
||||
///
|
||||
/// This will always reset the zoomed state.
|
||||
@@ -250,22 +257,22 @@ extension SplitTree {
|
||||
/// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists
|
||||
func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
|
||||
|
||||
// Find the path to the target node
|
||||
guard let path = root.path(to: node) else {
|
||||
throw SplitError.viewNotFound
|
||||
}
|
||||
|
||||
|
||||
// Determine which type of split we need to find based on resize direction
|
||||
let targetSplitDirection: Direction = switch direction {
|
||||
case .up, .down: .vertical
|
||||
case .left, .right: .horizontal
|
||||
}
|
||||
|
||||
|
||||
// Find the nearest parent split of the correct type by walking up the path
|
||||
var splitPath: Path?
|
||||
var splitNode: Node?
|
||||
|
||||
|
||||
for i in stride(from: path.path.count - 1, through: 0, by: -1) {
|
||||
let parentPath = Path(path: Array(path.path.prefix(i)))
|
||||
if let parent = root.node(at: parentPath), case .split(let split) = parent {
|
||||
@@ -276,29 +283,29 @@ extension SplitTree {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let splitPath = splitPath,
|
||||
|
||||
guard let splitPath = splitPath,
|
||||
let splitNode = splitNode,
|
||||
case .split(let split) = splitNode else {
|
||||
throw SplitError.viewNotFound
|
||||
}
|
||||
|
||||
|
||||
// Get current spatial representation to calculate pixel dimensions
|
||||
let spatial = root.spatial(within: bounds.size)
|
||||
guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else {
|
||||
throw SplitError.viewNotFound
|
||||
}
|
||||
|
||||
|
||||
// Calculate the new ratio based on pixel change
|
||||
let pixelOffset = Double(pixels)
|
||||
let newRatio: Double
|
||||
|
||||
|
||||
switch (split.direction, direction) {
|
||||
case (.horizontal, .left):
|
||||
// Moving left boundary: decrease left side
|
||||
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width)))
|
||||
case (.horizontal, .right):
|
||||
// Moving right boundary: increase left side
|
||||
// Moving right boundary: increase left side
|
||||
newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width)))
|
||||
case (.vertical, .up):
|
||||
// Moving top boundary: decrease top side
|
||||
@@ -310,7 +317,7 @@ extension SplitTree {
|
||||
// Direction doesn't match split type - shouldn't happen due to earlier logic
|
||||
throw SplitError.viewNotFound
|
||||
}
|
||||
|
||||
|
||||
// Create new split with adjusted ratio
|
||||
let newSplit = Node.Split(
|
||||
direction: split.direction,
|
||||
@@ -318,12 +325,12 @@ extension SplitTree {
|
||||
left: split.left,
|
||||
right: split.right
|
||||
)
|
||||
|
||||
|
||||
// Replace the split node with the new one
|
||||
let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
|
||||
return .init(root: newRoot, zoomed: nil)
|
||||
}
|
||||
|
||||
|
||||
/// Returns the total bounds of the split hierarchy using NSView bounds.
|
||||
/// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
|
||||
/// Also ignores any possible padding between views.
|
||||
@@ -334,6 +341,60 @@ extension SplitTree {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SplitTree Codable
|
||||
|
||||
fileprivate enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case root
|
||||
case zoomed
|
||||
|
||||
static let currentVersion: Int = 1
|
||||
}
|
||||
|
||||
extension SplitTree: Codable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
// Check version
|
||||
let version = try container.decode(Int.self, forKey: .version)
|
||||
guard version == CodingKeys.currentVersion else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Unsupported SplitTree version: \(version)"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Decode root
|
||||
self.root = try container.decodeIfPresent(Node.self, forKey: .root)
|
||||
|
||||
// Zoomed is encoded as its path. Get the path and then find it.
|
||||
if let zoomedPath = try container.decodeIfPresent(Path.self, forKey: .zoomed),
|
||||
let root = self.root {
|
||||
self.zoomed = root.node(at: zoomedPath)
|
||||
} else {
|
||||
self.zoomed = nil
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
// Encode version
|
||||
try container.encode(CodingKeys.currentVersion, forKey: .version)
|
||||
|
||||
// Encode root
|
||||
try container.encodeIfPresent(root, forKey: .root)
|
||||
|
||||
// Zoomed is encoded as its path since its a reference type. This lets us
|
||||
// map it on decode back to the correct node in root.
|
||||
if let zoomed, let path = root?.path(to: zoomed) {
|
||||
try container.encode(path, forKey: .zoomed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SplitTree.Node
|
||||
|
||||
extension SplitTree.Node {
|
||||
@@ -342,6 +403,23 @@ extension SplitTree.Node {
|
||||
typealias SplitError = SplitTree.SplitError
|
||||
typealias Path = SplitTree.Path
|
||||
|
||||
/// Find a node containing a view with the specified ID.
|
||||
/// - Parameter id: The ID of the view to find
|
||||
/// - Returns: The node containing the view if found, nil otherwise
|
||||
func find(id: ViewType.ID) -> Node? {
|
||||
switch self {
|
||||
case .leaf(let view):
|
||||
return view.id == id ? self : nil
|
||||
|
||||
case .split(let split):
|
||||
if let found = split.left.find(id: id) {
|
||||
return found
|
||||
}
|
||||
|
||||
return split.right.find(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the node in the tree that contains the given view.
|
||||
func node(view: ViewType) -> Node? {
|
||||
switch (self) {
|
||||
@@ -396,20 +474,20 @@ extension SplitTree.Node {
|
||||
|
||||
return search(self) ? Path(path: components) : nil
|
||||
}
|
||||
|
||||
|
||||
/// Returns the node at the given path from this node as root.
|
||||
func node(at path: Path) -> Node? {
|
||||
if path.isEmpty {
|
||||
return self
|
||||
}
|
||||
|
||||
|
||||
guard case .split(let split) = self else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
let component = path.path[0]
|
||||
let remainingPath = Path(path: Array(path.path.dropFirst()))
|
||||
|
||||
|
||||
switch component {
|
||||
case .left:
|
||||
return split.left.node(at: remainingPath)
|
||||
@@ -521,12 +599,12 @@ extension SplitTree.Node {
|
||||
if self == target {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
switch self {
|
||||
case .leaf:
|
||||
// A leaf that isn't the target stays as is
|
||||
return self
|
||||
|
||||
|
||||
case .split(let split):
|
||||
// Neither child is directly the target, so we need to recursively
|
||||
// try to remove from both children
|
||||
@@ -543,7 +621,7 @@ extension SplitTree.Node {
|
||||
} else if newRight == nil {
|
||||
return newLeft
|
||||
}
|
||||
|
||||
|
||||
// Both children still exist after removal
|
||||
return .split(.init(
|
||||
direction: split.direction,
|
||||
@@ -562,7 +640,7 @@ extension SplitTree.Node {
|
||||
case .leaf:
|
||||
// Leaf nodes don't have a ratio to resize
|
||||
return self
|
||||
|
||||
|
||||
case .split(let split):
|
||||
// Create a new split with the updated ratio
|
||||
return .split(.init(
|
||||
@@ -573,7 +651,7 @@ extension SplitTree.Node {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Get the leftmost leaf in this subtree
|
||||
func leftmostLeaf() -> ViewType {
|
||||
switch self {
|
||||
@@ -583,7 +661,7 @@ extension SplitTree.Node {
|
||||
return split.left.leftmostLeaf()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Get the rightmost leaf in this subtree
|
||||
func rightmostLeaf() -> ViewType {
|
||||
switch self {
|
||||
@@ -593,7 +671,7 @@ extension SplitTree.Node {
|
||||
return split.right.rightmostLeaf()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Equalize this node and all its children, returning a new node with splits
|
||||
/// adjusted so that each split's ratio is based on the relative weight
|
||||
/// (number of leaves) of its children.
|
||||
@@ -601,14 +679,14 @@ extension SplitTree.Node {
|
||||
let (equalizedNode, _) = equalizeWithWeight()
|
||||
return equalizedNode
|
||||
}
|
||||
|
||||
|
||||
/// Internal helper that equalizes and returns both the node and its weight.
|
||||
private func equalizeWithWeight() -> (node: Node, weight: Int) {
|
||||
switch self {
|
||||
case .leaf:
|
||||
// A leaf has weight 1 and doesn't change
|
||||
return (self, 1)
|
||||
|
||||
|
||||
case .split(let split):
|
||||
// Calculate weights based on split direction
|
||||
let leftWeight = split.left.weightForDirection(split.direction)
|
||||
@@ -629,7 +707,7 @@ extension SplitTree.Node {
|
||||
left: leftNode,
|
||||
right: rightNode
|
||||
)
|
||||
|
||||
|
||||
return (.split(newSplit), totalWeight)
|
||||
}
|
||||
}
|
||||
@@ -656,12 +734,12 @@ extension SplitTree.Node {
|
||||
switch self {
|
||||
case .leaf(let view):
|
||||
return [(view, bounds)]
|
||||
|
||||
|
||||
case .split(let split):
|
||||
// Calculate bounds for left and right based on split direction and ratio
|
||||
let leftBounds: CGRect
|
||||
let rightBounds: CGRect
|
||||
|
||||
|
||||
switch split.direction {
|
||||
case .horizontal:
|
||||
// Split horizontally: left | right
|
||||
@@ -678,7 +756,7 @@ extension SplitTree.Node {
|
||||
width: bounds.width * (1 - split.ratio),
|
||||
height: bounds.height
|
||||
)
|
||||
|
||||
|
||||
case .vertical:
|
||||
// Split vertically: top / bottom
|
||||
// Note: In our normalized coordinate system, Y increases upward
|
||||
@@ -696,13 +774,13 @@ extension SplitTree.Node {
|
||||
height: bounds.height * split.ratio
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Recursively calculate bounds for children
|
||||
return split.left.calculateViewBounds(in: leftBounds) +
|
||||
split.right.calculateViewBounds(in: rightBounds)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Returns the total bounds of this subtree using NSView bounds.
|
||||
/// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
|
||||
/// - Returns: The total width and height needed to contain all views in this subtree
|
||||
@@ -710,11 +788,11 @@ extension SplitTree.Node {
|
||||
switch self {
|
||||
case .leaf(let view):
|
||||
return view.bounds.size
|
||||
|
||||
|
||||
case .split(let split):
|
||||
let leftBounds = split.left.viewBounds()
|
||||
let rightBounds = split.right.viewBounds()
|
||||
|
||||
|
||||
switch split.direction {
|
||||
case .horizontal:
|
||||
// Horizontal split: width is sum, height is max
|
||||
@@ -722,7 +800,7 @@ extension SplitTree.Node {
|
||||
width: leftBounds.width + rightBounds.width,
|
||||
height: Swift.max(leftBounds.height, rightBounds.height)
|
||||
)
|
||||
|
||||
|
||||
case .vertical:
|
||||
// Vertical split: height is sum, width is max
|
||||
return CGSize(
|
||||
@@ -760,7 +838,7 @@ extension SplitTree.Node {
|
||||
/// // +--------+----+
|
||||
/// // | C | D |
|
||||
/// // +--------+----+
|
||||
/// //
|
||||
/// //
|
||||
/// // The spatial representation would have:
|
||||
/// // - Total dimensions: (width: 2, height: 2)
|
||||
/// // - Node bounds based on actual split ratios
|
||||
@@ -805,7 +883,7 @@ extension SplitTree.Node {
|
||||
/// Example:
|
||||
/// ```
|
||||
/// // Single leaf: (1, 1)
|
||||
/// // Horizontal split with 2 leaves: (2, 1)
|
||||
/// // Horizontal split with 2 leaves: (2, 1)
|
||||
/// // Vertical split with 2 leaves: (1, 2)
|
||||
/// // Complex layout with both: (2, 2) or larger
|
||||
/// ```
|
||||
@@ -846,7 +924,7 @@ extension SplitTree.Node {
|
||||
///
|
||||
/// The calculation process:
|
||||
/// 1. **Leaf nodes**: Create a single slot with the provided bounds
|
||||
/// 2. **Split nodes**:
|
||||
/// 2. **Split nodes**:
|
||||
/// - Divide the bounds according to the split ratio and direction
|
||||
/// - Create a slot for the split node itself
|
||||
/// - Recursively calculate slots for both children
|
||||
@@ -926,7 +1004,7 @@ extension SplitTree.Spatial {
|
||||
///
|
||||
/// This method finds all slots positioned in the given direction from the reference node:
|
||||
/// - **Left**: Slots with bounds to the left of the reference node
|
||||
/// - **Right**: Slots with bounds to the right of the reference node
|
||||
/// - **Right**: Slots with bounds to the right of the reference node
|
||||
/// - **Up**: Slots with bounds above the reference node (Y=0 is top)
|
||||
/// - **Down**: Slots with bounds below the reference node
|
||||
///
|
||||
@@ -955,41 +1033,41 @@ extension SplitTree.Spatial {
|
||||
let dy = rect2.minY - rect1.minY
|
||||
return sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
|
||||
let result = switch direction {
|
||||
case .left:
|
||||
// Slots to the left: their right edge is at or left of reference's left edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX
|
||||
$0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX
|
||||
}.sorted {
|
||||
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
|
||||
}
|
||||
|
||||
|
||||
case .right:
|
||||
// Slots to the right: their left edge is at or right of reference's right edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX
|
||||
$0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX
|
||||
}.sorted {
|
||||
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
|
||||
}
|
||||
|
||||
|
||||
case .up:
|
||||
// Slots above: their bottom edge is at or above reference's top edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY
|
||||
$0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY
|
||||
}.sorted {
|
||||
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
|
||||
}
|
||||
|
||||
|
||||
case .down:
|
||||
// Slots below: their top edge is at or below reference's bottom edge
|
||||
slots.filter {
|
||||
$0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY
|
||||
$0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY
|
||||
}.sorted {
|
||||
distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1008,14 +1086,14 @@ extension SplitTree.Spatial {
|
||||
func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool {
|
||||
// Find the slot for this node
|
||||
guard let slot = slots.first(where: { $0.node == node }) else { return false }
|
||||
|
||||
|
||||
// Calculate the overall bounds of all slots
|
||||
let overallBounds = slots.reduce(CGRect.null) { result, slot in
|
||||
result.union(slot.bounds)
|
||||
}
|
||||
|
||||
|
||||
return switch side {
|
||||
case .up:
|
||||
case .up:
|
||||
slot.bounds.minY == overallBounds.minY
|
||||
case .down:
|
||||
slot.bounds.maxY == overallBounds.maxY
|
||||
@@ -1052,10 +1130,10 @@ extension SplitTree.Node {
|
||||
case view
|
||||
case split
|
||||
}
|
||||
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
|
||||
if container.contains(.view) {
|
||||
let view = try container.decode(ViewType.self, forKey: .view)
|
||||
self = .leaf(view: view)
|
||||
@@ -1071,14 +1149,14 @@ extension SplitTree.Node {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
|
||||
switch self {
|
||||
case .leaf(let view):
|
||||
try container.encode(view, forKey: .view)
|
||||
|
||||
|
||||
case .split(let split):
|
||||
try container.encode(split, forKey: .split)
|
||||
}
|
||||
@@ -1093,7 +1171,7 @@ extension SplitTree.Node {
|
||||
switch self {
|
||||
case .leaf(let view):
|
||||
return [view]
|
||||
|
||||
|
||||
case .split(let split):
|
||||
return split.left.leaves() + split.right.leaves()
|
||||
}
|
||||
@@ -1145,7 +1223,7 @@ extension SplitTree.Node {
|
||||
var structuralIdentity: StructuralIdentity {
|
||||
StructuralIdentity(self)
|
||||
}
|
||||
|
||||
|
||||
/// A hashable representation of a node that captures its structural identity.
|
||||
///
|
||||
/// This type provides a way to track changes to a node's structure in SwiftUI
|
||||
@@ -1159,20 +1237,20 @@ extension SplitTree.Node {
|
||||
/// for unchanged portions of the tree.
|
||||
struct StructuralIdentity: Hashable {
|
||||
private let node: SplitTree.Node
|
||||
|
||||
|
||||
init(_ node: SplitTree.Node) {
|
||||
self.node = node
|
||||
}
|
||||
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.node.isStructurallyEqual(to: rhs.node)
|
||||
}
|
||||
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
node.hashStructure(into: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Checks if this node is structurally equal to another node.
|
||||
/// Two nodes are structurally equal if they have the same tree structure
|
||||
/// and the same views (by identity) in the same positions.
|
||||
@@ -1181,26 +1259,26 @@ extension SplitTree.Node {
|
||||
case let (.leaf(view1), .leaf(view2)):
|
||||
// Views must be the same instance
|
||||
return view1 === view2
|
||||
|
||||
|
||||
case let (.split(split1), .split(split2)):
|
||||
// Splits must have same direction and structurally equal children
|
||||
// Note: We intentionally don't compare ratios as they may change slightly
|
||||
return split1.direction == split2.direction &&
|
||||
split1.left.isStructurallyEqual(to: split2.left) &&
|
||||
split1.right.isStructurallyEqual(to: split2.right)
|
||||
|
||||
|
||||
default:
|
||||
// Different node types
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Hash keys for structural identity
|
||||
private enum HashKey: UInt8 {
|
||||
case leaf = 0
|
||||
case split = 1
|
||||
}
|
||||
|
||||
|
||||
/// Hashes the structural identity of this node.
|
||||
/// Includes the tree structure and view identities in the hash.
|
||||
fileprivate func hashStructure(into hasher: inout Hasher) {
|
||||
@@ -1208,7 +1286,7 @@ extension SplitTree.Node {
|
||||
case .leaf(let view):
|
||||
hasher.combine(HashKey.leaf)
|
||||
hasher.combine(ObjectIdentifier(view))
|
||||
|
||||
|
||||
case .split(let split):
|
||||
hasher.combine(HashKey.split)
|
||||
hasher.combine(split.direction)
|
||||
@@ -1247,17 +1325,17 @@ extension SplitTree {
|
||||
struct StructuralIdentity: Hashable {
|
||||
private let root: Node?
|
||||
private let zoomed: Node?
|
||||
|
||||
|
||||
init(_ tree: SplitTree) {
|
||||
self.root = tree.root
|
||||
self.zoomed = tree.zoomed
|
||||
}
|
||||
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
areNodesStructurallyEqual(lhs.root, rhs.root) &&
|
||||
areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed)
|
||||
}
|
||||
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(0) // Tree marker
|
||||
if let root = root {
|
||||
@@ -1268,7 +1346,7 @@ extension SplitTree {
|
||||
zoomed.hashStructure(into: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Helper to compare optional nodes for structural equality
|
||||
private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
|
@@ -860,7 +860,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
// Restore focus to the previously focused surface
|
||||
if let focusedUUID = undoState.focusedSurface,
|
||||
let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) {
|
||||
let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusTarget, from: nil)
|
||||
}
|
||||
@@ -875,7 +875,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
return .init(
|
||||
frame: window.frame,
|
||||
surfaceTree: surfaceTree,
|
||||
focusedSurface: focusedSurface?.uuid,
|
||||
focusedSurface: focusedSurface?.id,
|
||||
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
|
||||
tabGroup: window.tabGroup)
|
||||
}
|
||||
|
@@ -4,13 +4,13 @@ import Cocoa
|
||||
class TerminalRestorableState: Codable {
|
||||
static let selfKey = "state"
|
||||
static let versionKey = "version"
|
||||
static let version: Int = 4
|
||||
static let version: Int = 5
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
|
||||
init(from controller: TerminalController) {
|
||||
self.focusedSurface = controller.focusedSurface?.uuid.uuidString
|
||||
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||
self.surfaceTree = controller.surfaceTree
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
||||
if let focusedStr = state.focusedSurface {
|
||||
var foundView: Ghostty.SurfaceView?
|
||||
for view in c.surfaceTree {
|
||||
if view.uuid.uuidString == focusedStr {
|
||||
if view.id.uuidString == focusedStr {
|
||||
foundView = view
|
||||
break
|
||||
}
|
||||
|
@@ -31,9 +31,16 @@ class HiddenTitlebarTerminalWindow: TerminalWindow {
|
||||
.closable,
|
||||
.miniaturizable,
|
||||
]
|
||||
|
||||
|
||||
/// Apply the hidden titlebar style.
|
||||
private func reapplyHiddenStyle() {
|
||||
// If our window is fullscreen then we don't reapply the hidden style because
|
||||
// it can result in messing up non-native fullscreen. See:
|
||||
// https://github.com/ghostty-org/ghostty/issues/8415
|
||||
if terminalController?.fullscreenStyle?.isFullscreen ?? false {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply our style mask while preserving the .fullScreen option
|
||||
if styleMask.contains(.fullScreen) {
|
||||
styleMask = Self.hiddenStyleMask.union([.fullScreen])
|
||||
|
@@ -6,9 +6,11 @@ import GhosttyKit
|
||||
|
||||
extension Ghostty {
|
||||
/// The NSView implementation for a terminal surface.
|
||||
class SurfaceView: OSView, ObservableObject, Codable {
|
||||
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
|
||||
typealias ID = UUID
|
||||
|
||||
/// Unique ID per surface
|
||||
let uuid: UUID
|
||||
let id: UUID
|
||||
|
||||
// The current title of the surface as defined by the pty. This can be
|
||||
// changed with escape codes. This is public because the callbacks go
|
||||
@@ -180,7 +182,7 @@ extension Ghostty {
|
||||
|
||||
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
|
||||
self.markedText = NSMutableAttributedString()
|
||||
self.uuid = uuid ?? .init()
|
||||
self.id = uuid ?? .init()
|
||||
|
||||
// Our initial config always is our application wide config.
|
||||
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
|
||||
@@ -1468,7 +1470,7 @@ extension Ghostty {
|
||||
content.body = body
|
||||
content.sound = UNNotificationSound.default
|
||||
content.categoryIdentifier = Ghostty.userNotificationCategory
|
||||
content.userInfo = ["surface": self.uuid.uuidString]
|
||||
content.userInfo = ["surface": self.id.uuidString]
|
||||
|
||||
let uuid = UUID().uuidString
|
||||
let request = UNNotificationRequest(
|
||||
@@ -1576,7 +1578,7 @@ extension Ghostty {
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(pwd, forKey: .pwd)
|
||||
try container.encode(uuid.uuidString, forKey: .uuid)
|
||||
try container.encode(id.uuidString, forKey: .uuid)
|
||||
try container.encode(title, forKey: .title)
|
||||
try container.encode(titleFromTerminal != nil, forKey: .isUserSetTitle)
|
||||
}
|
||||
|
@@ -114,11 +114,13 @@ pub const App = struct {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{
|
||||
.major = 1,
|
||||
.minor = 0,
|
||||
.patch = 4,
|
||||
}) == .lt) {
|
||||
if (self.context.xdg_wm_dialog_present and
|
||||
layer_shell.getLibraryVersion().order(.{
|
||||
.major = 1,
|
||||
.minor = 0,
|
||||
.patch = 4,
|
||||
}) == .lt)
|
||||
{
|
||||
log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{});
|
||||
return false;
|
||||
}
|
||||
@@ -128,10 +130,7 @@ pub const App = struct {
|
||||
|
||||
pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void {
|
||||
const window = apprt_window.as(gtk.Window);
|
||||
|
||||
layer_shell.initForWindow(window);
|
||||
layer_shell.setLayer(window, .top);
|
||||
layer_shell.setNamespace(window, "ghostty-quick-terminal");
|
||||
}
|
||||
|
||||
fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type {
|
||||
@@ -411,6 +410,14 @@ pub const Window = struct {
|
||||
else
|
||||
return;
|
||||
|
||||
layer_shell.setLayer(window, switch (config.@"gtk-quick-terminal-layer") {
|
||||
.overlay => .overlay,
|
||||
.top => .top,
|
||||
.bottom => .bottom,
|
||||
.background => .background,
|
||||
});
|
||||
layer_shell.setNamespace(window, config.@"gtk-quick-terminal-namespace");
|
||||
|
||||
layer_shell.setKeyboardMode(
|
||||
window,
|
||||
switch (config.@"quick-terminal-keyboard-interactivity") {
|
||||
|
@@ -2144,6 +2144,44 @@ keybind: Keybinds = .{},
|
||||
/// terminal would be half a screen tall, and 500 pixels wide.
|
||||
@"quick-terminal-size": QuickTerminalSize = .{},
|
||||
|
||||
/// The layer of the quick terminal window. The higher the layer,
|
||||
/// the more windows the quick terminal may conceal.
|
||||
///
|
||||
/// Valid values are:
|
||||
///
|
||||
/// * `overlay`
|
||||
///
|
||||
/// The quick terminal appears in front of all windows.
|
||||
///
|
||||
/// * `top` (default)
|
||||
///
|
||||
/// The quick terminal appears in front of normal windows but behind
|
||||
/// fullscreen overlays like lock screens.
|
||||
///
|
||||
/// * `bottom`
|
||||
///
|
||||
/// The quick terminal appears behind normal windows but in front of
|
||||
/// wallpapers and other windows in the background layer.
|
||||
///
|
||||
/// * `background`
|
||||
///
|
||||
/// The quick terminal appears behind all windows.
|
||||
///
|
||||
/// GTK Wayland only.
|
||||
///
|
||||
/// Available since: 1.2.0
|
||||
@"gtk-quick-terminal-layer": QuickTerminalLayer = .top,
|
||||
/// The namespace for the quick terminal window.
|
||||
///
|
||||
/// This is an identifier that is used by the Wayland compositor and/or
|
||||
/// scripts to determine the type of layer surfaces and to possibly apply
|
||||
/// unique effects.
|
||||
///
|
||||
/// GTK Wayland only.
|
||||
///
|
||||
/// Available since: 1.2.0
|
||||
@"gtk-quick-terminal-namespace": [:0]const u8 = "ghostty-quick-terminal",
|
||||
|
||||
/// The screen where the quick terminal should show up.
|
||||
///
|
||||
/// Valid values are:
|
||||
@@ -5487,10 +5525,11 @@ pub const Keybinds = struct {
|
||||
else
|
||||
.{ .ctrl = true, .shift = true };
|
||||
|
||||
try self.set.put(
|
||||
try self.set.putFlags(
|
||||
alloc,
|
||||
.{ .key = .{ .unicode = 'c' }, .mods = mods },
|
||||
.{ .copy_to_clipboard = {} },
|
||||
.{ .performable = true },
|
||||
);
|
||||
try self.set.put(
|
||||
alloc,
|
||||
@@ -7202,6 +7241,14 @@ pub const QuickTerminalPosition = enum {
|
||||
center,
|
||||
};
|
||||
|
||||
/// See quick-terminal-layer
|
||||
pub const QuickTerminalLayer = enum {
|
||||
overlay,
|
||||
top,
|
||||
bottom,
|
||||
background,
|
||||
};
|
||||
|
||||
/// See quick-terminal-size
|
||||
pub const QuickTerminalSize = struct {
|
||||
primary: ?Size = null,
|
||||
|
Reference in New Issue
Block a user