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:
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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 ~3–4×
|
||||
/// 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. Don’t 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" = "添加后点「我已添加」,请勿改名。";
|
||||
|
||||
Reference in New Issue
Block a user