feat(keyboard): add Notes skill and system-style typing

Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
This commit is contained in:
Rocky
2026-08-14 18:22:41 +08:00
parent 0a60006d2d
commit 320dc777c9
46 changed files with 1429 additions and 376 deletions
@@ -1,9 +1,8 @@
// AIAgentShortcutRunner.swift
// OSGKeyboard · Main App
//
// Consumes the keyboard's pending export-skill payload and opens the
// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
// x-callback URLs so Console can record success / error / cancel.
// Consumes the keyboard's pending export-skill payload. Navigate opens a
// map URL in the host. Other exports open the companion Shortcut.
import UIKit
import OSGKeyboardShared
@@ -14,19 +13,23 @@ enum AIAgentShortcutRunner {
AIAgentShortcutRun.trace("host.runPending begin")
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
let catalog = AppGroupStore().agentUserSkillCatalog
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog),
let name = skill.shortcutName else {
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else {
AIAgentShortcutRun.trace(
"host.runPending skip unknownSkill=\(payload.skillID)"
)
return
}
guard let text = shortcutText(for: skill, payload: payload) else {
if skill.id == AIClipboardSkillCatalog.navigateID {
openMap(for: payload)
return
}
guard let name = skill.shortcutName else {
AIAgentShortcutRun.trace(
"host.runPending skip textBuildFailed skill=\(payload.skillID)"
"host.runPending skip missingShortcut skill=\(payload.skillID)"
)
return
}
let text = payload.joinedTitles
AIAgentShortcutRun.traceBody("host.titlesToShortcut", text)
guard let url = shortcutsURL(name: name, text: text) else {
AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
@@ -62,19 +65,22 @@ enum AIAgentShortcutRunner {
)
}
/// Navigate: pick Apple Maps, pass that URL to the Shortcut.
/// Other export skills send the parsed lines unchanged.
private static func shortcutText(
for skill: AIClipboardSkill,
payload: AIAgentShortcutRunPayload
) -> String? {
if skill.id == AIClipboardSkillCatalog.navigateID {
return AIMapNavigation.shortcutInput(
from: payload.joinedTitles,
canOpen: { UIApplication.shared.canOpenURL($0) }
)
/// Apple Maps. Do not bounce through Shortcuts.
private static func openMap(for payload: AIAgentShortcutRunPayload) {
guard let urlString = AIMapNavigation.shortcutInput(
from: payload.joinedTitles,
canOpen: { UIApplication.shared.canOpenURL($0) }
), let url = URL(string: urlString) else {
AIAgentShortcutRun.trace("host.runPending skip navigateURLBuildFailed")
return
}
AIAgentShortcutRun.trace(
"host.openMap scheme=\(url.scheme ?? "") host=\(url.host ?? "")"
)
AIAgentShortcutRun.traceBody("host.mapURL", urlString)
UIApplication.shared.open(url) { success in
AIAgentShortcutRun.trace("host.openMap result success=\(success)")
}
return payload.joinedTitles
}
private static func shortcutsURL(name: String, text: String) -> URL? {
@@ -15,23 +15,6 @@ enum AppTab: Int, CaseIterable {
case styles
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
case .skills, .styles: return .menuBook // unused these tabs use SF Symbols
case .settings: return .settings
}
}
/// SF Symbol overrides shared with the Mac and iPad sidebars.
var sfSymbol: String? {
switch self {
case .skills: return "sparkles"
case .styles: return "text.badge.star"
default: return nil
}
}
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
@@ -41,15 +24,28 @@ enum AppTab: Int, CaseIterable {
}
}
/// Phone dock: outline when idle, fill when selected (system tab-bar
/// convention). `wand.and.sparkles` has no `.fill` pair.
func dockSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
var sidebarTitle: LocalizedStringKey { accessibilityKey }
var sidebarSystemImage: String {
/// iPad sidebar. Home stays `house` in both states; other tabs follow
/// the same outline / fill pairing as the phone dock.
func sidebarSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return "house"
case .skills: return "sparkles"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
}
@@ -69,14 +65,8 @@ struct MinimalTabBar: View {
}
} label: {
VStack(spacing: 2) {
Group {
if let sfSymbol = tab.sfSymbol {
Image(systemName: sfSymbol)
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
} else {
MaterialIcon(name: tab.icon, size: TabBarDockMetrics.iconSize)
}
}
Image(systemName: tab.dockSystemImage(selected: selection == tab))
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
Text(tab.accessibilityKey)
.font(TypeStyle.caption2)
.lineLimit(1)
@@ -52,13 +52,13 @@ extension View {
}
enum TabBarDockMetrics {
static let itemHeight: CGFloat = 52
static let iconSize: CGFloat = 24
static let itemHeight: CGFloat = 46
static let iconSize: CGFloat = 22
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~34×
/// the 5 pt top/bottom gap.
static let dockInsetHorizontal: CGFloat = 0
static let dockInsetVertical: CGFloat = Spacing.sm
static let dockInsetVertical: CGFloat = Spacing.xs
/// Gap between the selected fill and the glass dock / neighbouring tabs.
static let selectionInset: CGFloat = 5
static let bottomPadding: CGFloat = Spacing.xs
+2 -2
View File
@@ -110,7 +110,7 @@ private struct WideSidebarRow: View {
var body: some View {
Button(action: action) {
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage)
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage(selected: isSelected))
.labelStyle(SidebarIconColumnLabelStyle())
// Slightly larger label + taller vertical padding so each
// sidebar row hits the Apple HIG 44pt touch target on iPad
@@ -144,7 +144,7 @@ private struct WideSidebarRow: View {
/// (`house`) enough to push titles apart by ~5pt. `List` reserves that column
/// automatically; this hand-rolled `VStack` sidebar has to do it itself.
private struct SidebarIconColumnLabelStyle: LabelStyle {
/// Wider than the widest symbol in `AppTab.sidebarSystemImage`.
/// Wider than the widest symbol in `AppTab.sidebarSystemImage(selected:)`.
private static let iconColumnWidth: CGFloat = 22
func makeBody(configuration: Configuration) -> some View {
+2
View File
@@ -457,6 +457,8 @@
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
"skills.extractEvents.name" = "Extract events";
"skills.extractEvents.description" = "Extract events from copied text into Calendar.";
"skills.saveToNotes.name" = "Save to Notes";
"skills.saveToNotes.description" = "Create a new note from the copied text. The title comes from the time and content; the body stays as copied.";
"skills.navigate.name" = "Navigate";
"skills.navigate.description" = "Find an address in the copied text and start driving directions. Uses Amap if installed, then Baidu Maps, then Apple Maps.";
"skills.install.lead" = "Add it, then confirm. Dont rename it.";
@@ -456,6 +456,8 @@
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
"skills.extractEvents.name" = "提取日程";
"skills.extractEvents.description" = "从复制内容提取日程,写入日历。";
"skills.saveToNotes.name" = "存入备忘录";
"skills.saveToNotes.description" = "根据复制内容和当前时间生成标题,写入一条新的备忘录;正文保持原文。";
"skills.navigate.name" = "导航";
"skills.navigate.description" = "从复制内容识别地址并开始驾车导航。优先高德,其次百度,最后 Apple 地图。";
"skills.install.lead" = "添加后点「我已添加」,请勿改名。";