feat: add floating glass sidebar
This commit is contained in:
1 parent
4f8e6b914e
commit
e9d4335db9
3 files changed
+249
-19
No files matched your search
@@ -438,6 +438,7 @@ final class DictationCoordinator {
|
||||
dictionaryEntryCount: dictionaryEntryCount
|
||||
)
|
||||
history.save(session)
|
||||
NotificationCenter.default.post(name: .openLessHistoryChanged, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - 凭据读取(全部走内存快照,不打 Keychain)
|
||||
|
||||
@@ -53,7 +53,8 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
FixedSidebar(selection: $navigation.selection)
|
||||
Divider()
|
||||
.padding(.leading, 18)
|
||||
.padding(.vertical, 18)
|
||||
Group {
|
||||
switch navigation.selection {
|
||||
case .home: HomeTab()
|
||||
@@ -64,26 +65,32 @@ struct SettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background(WindowCanvasBackground())
|
||||
.frame(minWidth: 1040, minHeight: 700)
|
||||
}
|
||||
}
|
||||
|
||||
private struct FixedSidebar: View {
|
||||
@Binding var selection: OpenLessMainTab
|
||||
@State private var stats = SidebarStatsSnapshot.load()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
WindowControlGlassDock()
|
||||
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("OpenLess")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.font(.system(size: 23, weight: .semibold))
|
||||
Text("自然说话,完美书写")
|
||||
.font(.footnote)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 24)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassPanel(cornerRadius: 18)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(OpenLessMainTab.allCases) { tab in
|
||||
Button {
|
||||
selection = tab
|
||||
@@ -99,17 +106,20 @@ private struct FixedSidebar: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
selection == tab ? Color.accentColor.opacity(0.16) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(selection == tab ? .primary : .secondary)
|
||||
.background(
|
||||
selection == tab ? Color.accentColor.opacity(0.13) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
)
|
||||
.glassPanel(cornerRadius: 15)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(tab.title)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
SidebarUsageCard(stats: stats)
|
||||
SidebarConnectionCard(stats: stats)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -119,11 +129,207 @@ private struct FixedSidebar: View {
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 20)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassPanel(cornerRadius: 18)
|
||||
}
|
||||
.frame(width: 224)
|
||||
.background(.regularMaterial)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 12)
|
||||
.frame(width: 264)
|
||||
.glassPanel(cornerRadius: 32)
|
||||
.onAppear { refresh() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openLessHistoryChanged)) { _ in refresh() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openLessDictionaryChanged)) { _ in refresh() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in refresh() }
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
stats = SidebarStatsSnapshot.load()
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowCanvasBackground: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.primary.opacity(0.035),
|
||||
Color.clear,
|
||||
Color.accentColor.opacity(0.035),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowControlGlassDock: View {
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color.clear)
|
||||
.frame(width: 92, height: 36)
|
||||
.glassPanel(cornerRadius: 18)
|
||||
.padding(.top, 2)
|
||||
.padding(.leading, 2)
|
||||
.help("窗口控制")
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarUsageCard: View {
|
||||
let stats: SidebarStatsSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 11) {
|
||||
HStack {
|
||||
Label("今日概览", systemImage: "chart.bar.xaxis")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(stats.sessionCountText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
MiniUsageChart(values: stats.chartValues)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||
SidebarMetricBox(title: "时长", value: stats.durationText, symbol: "waveform")
|
||||
SidebarMetricBox(title: "总字数", value: "\(stats.totalCharacters)", symbol: "number")
|
||||
SidebarMetricBox(title: "每分钟", value: "\(stats.charactersPerMinute)", symbol: "speedometer")
|
||||
SidebarMetricBox(title: "词条", value: "\(stats.dictionaryCount)", symbol: "text.book.closed")
|
||||
}
|
||||
}
|
||||
.padding(13)
|
||||
.glassPanel(cornerRadius: 20)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarConnectionCard: View {
|
||||
let stats: SidebarStatsSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
SidebarConnectionRow(title: "ASR", detail: stats.hasVolcCredentials ? "已配置" : "待配置", ok: stats.hasVolcCredentials)
|
||||
SidebarConnectionRow(title: "润色", detail: stats.hasArkCredentials ? "已配置" : "原文兜底", ok: stats.hasArkCredentials)
|
||||
}
|
||||
.padding(13)
|
||||
.glassPanel(cornerRadius: 20)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarMetricBox: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let symbol: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Image(systemName: symbol)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.system(size: 17, weight: .semibold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, minHeight: 82, alignment: .leading)
|
||||
.glassPanel(cornerRadius: 15)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarConnectionRow: View {
|
||||
let title: String
|
||||
let detail: String
|
||||
let ok: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(ok ? Color.green : Color.orange)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(ok ? .primary : .secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.glassPanel(cornerRadius: 13)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiniUsageChart: View {
|
||||
let values: [Double]
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 5) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.72))
|
||||
.frame(height: max(8, 44 * value))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(height: 66)
|
||||
.glassPanel(cornerRadius: 15)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarStatsSnapshot {
|
||||
let totalSeconds: Double
|
||||
let totalCharacters: Int
|
||||
let charactersPerMinute: Int
|
||||
let dictionaryCount: Int
|
||||
let hasVolcCredentials: Bool
|
||||
let hasArkCredentials: Bool
|
||||
let chartValues: [Double]
|
||||
let sessionCount: Int
|
||||
|
||||
var durationText: String {
|
||||
if totalSeconds < 60 {
|
||||
return "\(Int(totalSeconds.rounded())) 秒"
|
||||
}
|
||||
return String(format: "%.1f 分", totalSeconds / 60)
|
||||
}
|
||||
|
||||
var sessionCountText: String {
|
||||
"\(sessionCount) 次"
|
||||
}
|
||||
|
||||
static func load() -> SidebarStatsSnapshot {
|
||||
let sessions = HistoryStore().recent(limit: 100)
|
||||
let totalCharacters = sessions.reduce(0) { $0 + $1.finalText.count }
|
||||
let actualMs = sessions.compactMap(\.durationMs).reduce(0, +)
|
||||
let totalSeconds = actualMs > 0 ? Double(actualMs) / 1000 : Double(totalCharacters) / 240 * 60
|
||||
let charactersPerMinute = totalSeconds > 0 ? Int((Double(totalCharacters) / totalSeconds * 60).rounded()) : 0
|
||||
let recentCounts = Array(sessions.prefix(7).reversed()).map { Double(max($0.finalText.count, 1)) }
|
||||
let maxCount = recentCounts.max() ?? 1
|
||||
let chartValues = recentCounts.isEmpty ? Array(repeating: 0.18, count: 7) : recentCounts.map { $0 / maxCount }
|
||||
let vault = CredentialsVault.shared
|
||||
|
||||
return SidebarStatsSnapshot(
|
||||
totalSeconds: totalSeconds,
|
||||
totalCharacters: totalCharacters,
|
||||
charactersPerMinute: charactersPerMinute,
|
||||
dictionaryCount: DictionaryStore().enabledEntries().count,
|
||||
hasVolcCredentials: isFilled(vault.get(CredentialAccount.volcengineAppKey)) && isFilled(vault.get(CredentialAccount.volcengineAccessKey)),
|
||||
hasArkCredentials: isFilled(vault.get(CredentialAccount.arkApiKey)),
|
||||
chartValues: chartValues,
|
||||
sessionCount: sessions.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,6 +1371,7 @@ private struct PrivacyTab: View {
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let openLessHistoryChanged = Notification.Name("openless.history_changed")
|
||||
static let openLessHotkeyChanged = Notification.Name("openless.hotkey_changed")
|
||||
static let openLessCredentialsChanged = Notification.Name("openless.credentials_changed")
|
||||
static let openLessDictionaryChanged = Notification.Name("openless.dictionary_changed")
|
||||
|
||||
@@ -17,8 +17,10 @@ final class SettingsWindowController {
|
||||
let win = NSWindow(contentViewController: hosting)
|
||||
win.title = "OpenLess"
|
||||
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
|
||||
win.titlebarAppearsTransparent = false
|
||||
win.toolbarStyle = .unified
|
||||
win.titleVisibility = .hidden
|
||||
win.titlebarAppearsTransparent = true
|
||||
win.toolbar = nil
|
||||
win.isMovableByWindowBackground = true
|
||||
win.setContentSize(NSSize(width: 1040, height: 700))
|
||||
win.contentMinSize = NSSize(width: 960, height: 640)
|
||||
win.tabbingMode = .disallowed
|
||||
@@ -27,5 +29,25 @@ final class SettingsWindowController {
|
||||
self.window = win
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
DispatchQueue.main.async { [weak self, weak win] in
|
||||
guard let win else { return }
|
||||
self?.alignTrafficLights(in: win)
|
||||
}
|
||||
}
|
||||
|
||||
private func alignTrafficLights(in window: NSWindow) {
|
||||
let buttons: [NSButton?] = [
|
||||
window.standardWindowButton(.closeButton),
|
||||
window.standardWindowButton(.miniaturizeButton),
|
||||
window.standardWindowButton(.zoomButton),
|
||||
]
|
||||
for (index, button) in buttons.compactMap({ $0 }).enumerated() {
|
||||
var frame = button.frame
|
||||
frame.origin.x = 34 + CGFloat(index) * 24
|
||||
if let superview = button.superview, superview.bounds.height > 80 {
|
||||
frame.origin.y = superview.bounds.height - 38
|
||||
}
|
||||
button.frame = frame
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user