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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Connection-first labels with the database or schema on a second line in the connections strip. (#2550)

## [0.69.0] - 2026-08-27

### Added
Expand Down
82 changes: 71 additions & 11 deletions TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import AppKit
import SwiftUI

/// One workspace: a glyph above the container it browses, with the connection's own colour as a
/// dot on the glyph's corner.
/// One workspace: a glyph above its connection and container, with the connection's own colour as
/// a dot on the glyph's corner.
///
/// Three channels share this cell and each owns a different property of the same glyph. Its SHAPE
/// is the connection's state, because a colour-only difference between failed and disconnected is
Expand All @@ -26,10 +26,9 @@ import SwiftUI
/// is what keeps it legible against the accent fill, which seven of the eight palette colours
/// otherwise fail 3:1 against.
///
/// The label keeps its single line and its middle truncation. Wrapping to two was tried and is
/// worse: underscore is Unicode class AL and offers no break, so `tablepro_license` breaks at the
/// width limit into `tablepro_licens` and an orphaned `e`. The HIG's reason for middle truncation
/// in a narrow column stands, and the full name is in the tooltip and the accessibility label.
/// Connection and container occupy explicit lines rather than wrapping one name. Each semantic
/// value keeps one line and its own middle truncation, and the full identity stays in the tooltip
/// and accessibility label.
@MainActor
internal final class WorkspaceRailCellView: NSTableCellView {
internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("WorkspaceRailCell")
Expand All @@ -41,6 +40,7 @@ internal final class WorkspaceRailCellView: NSTableCellView {
private var iconHeightConstraint: NSLayoutConstraint?
private var dotWidthConstraint: NSLayoutConstraint?
private var dotHeightConstraint: NSLayoutConstraint?
private var labelTopConstraint: NSLayoutConstraint?
private var appliedTint: NSColor?
/// Held as the palette entry rather than a resolved colour, because `systemRed` and the rest
/// differ between light and dark: resolving at configure time would freeze the dot at the
Expand All @@ -66,8 +66,9 @@ internal final class WorkspaceRailCellView: NSTableCellView {

label.translatesAutoresizingMaskIntoConstraints = false
label.alignment = .center
label.usesSingleLineMode = false
label.lineBreakMode = .byTruncatingMiddle
label.maximumNumberOfLines = 1
label.maximumNumberOfLines = 2
label.allowsExpansionToolTips = true
label.cell?.truncatesLastVisibleLine = true

Expand All @@ -91,15 +92,22 @@ internal final class WorkspaceRailCellView: NSTableCellView {
dotWidthConstraint = dotWidth
dotHeightConstraint = dotHeight

let labelTop = label.topAnchor.constraint(
equalTo: icon.bottomAnchor,
constant: Self.labelTopSpacing(forIcon: 24)
)
labelTopConstraint = labelTop

NSLayoutConstraint.activate([
icon.centerXAnchor.constraint(equalTo: centerXAnchor),
icon.topAnchor.constraint(equalTo: topAnchor, constant: 6),
icon.topAnchor.constraint(equalTo: topAnchor, constant: 1),
width,
height,

label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 3),
labelTop,
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2),
label.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor),

dotWidth,
dotHeight,
Expand All @@ -115,6 +123,14 @@ internal final class WorkspaceRailCellView: NSTableCellView {
(iconSize * 0.375).rounded()
}

internal static func labelTopSpacing(forIcon iconSize: CGFloat) -> CGFloat {
ceil(identityDotSize(forIcon: iconSize) / 2) + 1
}

internal static func secondaryFontSize(for primaryFontSize: CGFloat) -> CGFloat {
max(10, primaryFontSize - 1)
}

private static let identityDotRimWidth: CGFloat = 1.5

internal func configure(entry: WorkspaceRailEntry, layout: WorkspaceRailMetrics.Layout) {
Expand All @@ -125,9 +141,9 @@ internal final class WorkspaceRailCellView: NSTableCellView {
dotWidthConstraint?.constant = dotSize
dotHeightConstraint?.constant = dotSize
identityDot.layer?.cornerRadius = dotSize / 2
labelTopConstraint?.constant = Self.labelTopSpacing(forIcon: layout.iconSize)

label.font = .systemFont(ofSize: layout.fontSize)
label.stringValue = entry.container.isEmpty ? entry.connection.name : entry.container
label.attributedStringValue = Self.labelValue(for: entry, layout: layout)

appliedTint = Self.glyphTint(for: entry)
identityColor = entry.connection.identityColor
Expand All @@ -139,6 +155,50 @@ internal final class WorkspaceRailCellView: NSTableCellView {
setAccessibilityLabel(Self.voiceOverLabel(for: entry))
}

private static func labelValue(
for entry: WorkspaceRailEntry,
layout: WorkspaceRailMetrics.Layout
) -> NSAttributedString {
let lines = labelLines(for: entry)
guard let primary = lines.first else { return NSAttributedString() }

let paragraph = NSMutableParagraphStyle()
paragraph.alignment = .center
paragraph.lineBreakMode = .byTruncatingMiddle

let value = NSMutableAttributedString(
string: primary,
attributes: [
.font: NSFont.systemFont(ofSize: layout.fontSize),
.foregroundColor: NSColor.labelColor,
.paragraphStyle: paragraph,
]
)
guard lines.count == 2 else { return value }

value.append(NSAttributedString(
string: "\n\(lines[1])",
attributes: [
.font: NSFont.systemFont(ofSize: secondaryFontSize(for: layout.fontSize)),
.foregroundColor: NSColor.secondaryLabelColor,
.paragraphStyle: paragraph,
]
))
return value
}

private static func labelLines(for entry: WorkspaceRailEntry) -> [String] {
let connection = singleLine(entry.connection.name)
let container = singleLine(entry.container)
guard !connection.isBlank else { return container.isEmpty ? [] : [container] }
guard !container.isEmpty else { return [connection] }
return [connection, container]
}

private static func singleLine(_ value: String) -> String {
value.components(separatedBy: .newlines).joined(separator: " ")
}

/// On the selected row `NSTableCellView` already tints the image view for contrast, so the
/// glyph steps aside rather than competing with the selection fill, which it loses against at
/// every accent colour. The identity dot does not step aside with it: it is the one thing on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,41 @@ internal final class WorkspaceRailTableView: NSTableView {
}
}

@MainActor
internal enum WorkspaceRailTypeSelect {
internal static func nextMatch(
in entries: [WorkspaceRailEntry],
from startRow: Int,
to endRow: Int,
search: String
) -> Int {
guard !search.isEmpty,
entries.indices.contains(startRow),
entries.indices.contains(endRow) else { return -1 }

var row = startRow
repeat {
if matches(entries[row], search: search) { return row }
row = (row + 1) % entries.count
} while row != endRow
return -1
}

private static func matches(_ entry: WorkspaceRailEntry, search: String) -> Bool {
matchesPrefix(entry.connection.name, search: search)
|| matchesPrefix(entry.container, search: search)
Comment on lines +65 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize names before type-select matching

When a pasted or imported connection name contains a leading line separator, the cell flattens that separator to a space, but type-select compares against the unmodified value. Such names are accepted because connection validation trims only .whitespaces, so a displayed name such as " Production" cannot be found by typing its visible alphabetic prefix production. Match the same single-line representation used by the cell.

Useful? React with 👍 / 👎.

}

private static func matchesPrefix(_ value: String, search: String) -> Bool {
guard !value.isEmpty else { return false }
return value.range(
of: search,
options: [.anchored, .caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: .current
) != nil
}
}

@MainActor
internal final class WorkspaceRailViewController: NSViewController {
nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "WorkspaceRail")
Expand Down Expand Up @@ -821,6 +856,20 @@ extension WorkspaceRailViewController: NSTableViewDelegate {
return cell
}

internal func tableView(
_ tableView: NSTableView,
nextTypeSelectMatchFromRow startRow: Int,
toRow endRow: Int,
for searchString: String
) -> Int {
WorkspaceRailTypeSelect.nextMatch(
in: entries,
from: startRow,
to: endRow,
search: searchString
)
}

/// Selection is the highlight, not the commit.
///
/// `NSTableView` selects on mouse-down, before the drag threshold, so committing here meant
Expand Down
120 changes: 112 additions & 8 deletions TableProTests/Core/Services/WorkspaceRailCellRenderingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,27 @@ import Testing
struct WorkspaceRailCellRenderingTests {
private static let layout = WorkspaceRailMetrics.medium

private func cell(color: ConnectionColor, status: ConnectionStatus = .connected) -> WorkspaceRailCellView {
var connection = TestFixtures.makeConnection(database: "app")
connection.name = "production"
private func cell(
name: String = "production",
container: String = "app",
color: ConnectionColor,
status: ConnectionStatus = .connected,
layout: WorkspaceRailMetrics.Layout = WorkspaceRailMetrics.medium
) -> WorkspaceRailCellView {
var connection = TestFixtures.makeConnection(database: container)
connection.name = name
connection.color = color
let entry = WorkspaceRailEntry(
workspace: WorkspaceID(connectionId: connection.id, container: "app"),
workspace: WorkspaceID(connectionId: connection.id, container: container),
connection: connection,
status: status,
containerTarget: .database
)

let view = WorkspaceRailCellView(frame: NSRect(
x: 0, y: 0, width: Self.layout.width, height: Self.layout.rowHeight
x: 0, y: 0, width: layout.width, height: layout.rowHeight
))
view.configure(entry: entry, layout: Self.layout)
view.configure(entry: entry, layout: layout)
view.layoutSubtreeIfNeeded()
view.displayIfNeeded()
return view
Expand All @@ -47,9 +53,23 @@ struct WorkspaceRailCellRenderingTests {
}

private func pixelCount(_ rep: NSBitmapImageRep, matching predicate: (NSColor) -> Bool) -> Int {
pixelCount(
rep,
in: NSRect(x: 0, y: 0, width: rep.pixelsWide, height: rep.pixelsHigh),
matching: predicate
)
}

private func pixelCount(
_ rep: NSBitmapImageRep,
in rect: NSRect,
matching predicate: (NSColor) -> Bool
) -> Int {
var count = 0
for y in 0 ..< rep.pixelsHigh {
for x in 0 ..< rep.pixelsWide {
let xRange = max(0, Int(rect.minX)) ..< min(rep.pixelsWide, Int(ceil(rect.maxX)))
let yRange = max(0, Int(rect.minY)) ..< min(rep.pixelsHigh, Int(ceil(rect.maxY)))
for y in yRange {
for x in xRange {
guard let raw = rep.colorAt(x: x, y: y),
let color = raw.usingColorSpace(.sRGB),
color.alphaComponent > 0.5 else { continue }
Expand All @@ -59,10 +79,36 @@ struct WorkspaceRailCellRenderingTests {
return count
}

private func differingPixelCount(_ lhs: NSBitmapImageRep, _ rhs: NSBitmapImageRep) -> Int {
guard lhs.pixelsWide == rhs.pixelsWide, lhs.pixelsHigh == rhs.pixelsHigh else { return .max }
var count = 0
for y in 0 ..< lhs.pixelsHigh {
for x in 0 ..< lhs.pixelsWide where lhs.colorAt(x: x, y: y) != rhs.colorAt(x: x, y: y) {
count += 1
}
}
return count
}

private func bitmapRect(_ viewRect: NSRect, in view: NSView, rep: NSBitmapImageRep) -> NSRect {
let scaleX = CGFloat(rep.pixelsWide) / view.bounds.width
let scaleY = CGFloat(rep.pixelsHigh) / view.bounds.height
return NSRect(
x: viewRect.minX * scaleX,
y: (view.bounds.maxY - viewRect.maxY) * scaleY,
width: viewRect.width * scaleX,
height: viewRect.height * scaleY
)
}

private func isRedish(_ color: NSColor) -> Bool {
color.redComponent > 0.55 && color.greenComponent < 0.45 && color.blueComponent < 0.45
}

private func isSelectedText(_ color: NSColor) -> Bool {
color.redComponent > 0.75 && color.greenComponent > 0.75 && color.blueComponent > 0.75
}

/// The defect this suite exists for. The band covered the label's whole width; a dot may not
/// cover more than a small fraction of the cell, whatever colour the user picks.
@Test("The identity colour never covers more than a fraction of the cell")
Expand Down Expand Up @@ -92,11 +138,69 @@ struct WorkspaceRailCellRenderingTests {
#expect(pixelCount(rep, matching: isRedish) > 0, "identity vanished on the selected row")
}

@Test("Both identity lines adapt to the selected-row foreground")
func labelLinesAdaptToSelection() throws {
let view = cell(color: .none)
view.backgroundStyle = .emphasized
view.layoutSubtreeIfNeeded()
view.displayIfNeeded()
let rep = try #require(render(view))
let label = try #require(view.textField)
let primaryInView = NSRect(
x: label.frame.minX,
y: label.frame.midY,
width: label.frame.width,
height: label.frame.height / 2
)
let secondaryInView = NSRect(
x: label.frame.minX,
y: label.frame.minY,
width: label.frame.width,
height: label.frame.height / 2
)
let primary = bitmapRect(primaryInView, in: view, rep: rep)
let secondary = bitmapRect(secondaryInView, in: view, rep: rep)

#expect(pixelCount(rep, in: primary, matching: isSelectedText) > 0)
#expect(pixelCount(rep, in: secondary, matching: isSelectedText) > 0)
}

@Test("A connection with no colour paints no identity")
func uncolouredPaintsNothing() throws {
let view = cell(color: .none)
let rep = try #require(render(view))

#expect(pixelCount(rep, matching: isRedish) == 0)
}

@Test("Connections with one container keep visibly different identities")
func duplicateContainersKeepVisibleConnectionIdentity() throws {
let production = try #require(render(cell(
name: "podo-prod", container: "gwatop", color: .none
)))
let staging = try #require(render(cell(
name: "podo-stage", container: "gwatop", color: .none
)))

#expect(
differingPixelCount(production, staging) > 100,
"different connections rendered as the same rail entry"
)
}

@Test("Two label lines fit every rail size without touching the identity dot")
func labelFitsEveryLayout() throws {
for layout in [WorkspaceRailMetrics.small, WorkspaceRailMetrics.medium, WorkspaceRailMetrics.large] {
let view = cell(
name: "podo-stage", container: "gwatop", color: .red,
layout: layout
)
let label = try #require(view.textField)
let icon = try #require(view.imageView)
let dot = try #require(view.subviews.first { $0 !== label && $0 !== icon })

#expect(view.bounds.contains(label.frame), "label escaped the \(layout) row")
#expect(dot.frame.minY >= label.frame.maxY, "identity dot overlapped the label in \(layout)")
}
}
}
Loading
Loading