ShoechooApp (@main)
+--------------------------------------------------+
| DocumentGroup |
| newDocument: MarkdownDocument() |
| editor: { config in |
| EditorView(document: config.document) |
| } |
| |
| Settings |
| PreferencesView() |
+--------------------------------------------------+
| .commands { |
| FileCommands() |
| FormatCommands() |
| ViewCommands() |
| } |
+--------------------------------------------------+
EditorView
+---------------------------------------------------+
| .toolbar { |
| ToolbarItemGroup(placement: .automatic) |
| [H1] [H2] [H3] | [B] [I] [S] [Code] | ... |
| } |
+---------------------------------------------------+
| WYSIWYGTextView (NSViewRepresentable — Unit 1) |
| |
| Full WYSIWYG editor area |
| Backed by ShoechooTextView (NSTextView) |
| TextKit 2 stack |
| |
+---------------------------------------------------+
struct EditorToolbar: ToolbarContent {
@Bindable var viewModel: EditorViewModel
var body: some ToolbarContent {
ToolbarItemGroup(placement: .automatic) {
// Heading group
Button { viewModel.setHeading(level: 1) } label: {
Label("Heading 1", systemImage: "number")
}
.help("Heading 1 (Cmd+1)")
Button { viewModel.setHeading(level: 2) } label: {
Label("Heading 2", systemImage: "number")
}
.help("Heading 2 (Cmd+2)")
Button { viewModel.setHeading(level: 3) } label: {
Label("Heading 3", systemImage: "number")
}
.help("Heading 3 (Cmd+3)")
Divider()
// Inline formatting group
Button { viewModel.toggleBold() } label: {
Label("Bold", systemImage: "bold")
}
.help("Bold (Cmd+B)")
Button { viewModel.toggleItalic() } label: {
Label("Italic", systemImage: "italic")
}
.help("Italic (Cmd+I)")
Button { viewModel.toggleStrikethrough() } label: {
Label("Strikethrough", systemImage: "strikethrough")
}
.help("Strikethrough")
Button { viewModel.toggleInlineCode() } label: {
Label("Inline Code", systemImage: "chevron.left.forwardslash.chevron.right")
}
.help("Inline Code (Cmd+Shift+K)")
Divider()
// Insert group
Button { viewModel.insertLink() } label: {
Label("Link", systemImage: "link")
}
.help("Link (Cmd+K)")
Button { viewModel.insertImage() } label: {
Label("Image", systemImage: "photo")
}
.help("Insert Image")
}
}
}
PreferencesView (Settings scene)
+---------------------------------------------------+
| TabView |
| +-----------------------------------------------+ |
| | [Editor] [Appearance] | |
| +-----------------------------------------------+ |
| | | |
| | Editor Tab: | |
| | Font Family: [Picker: SF Mono v] | |
| | Font Size: [Stepper: 14 pt] | |
| | Line Spacing: [Slider: 1.4x] | |
| | | |
| | [ ] Enable Focus Mode by default | |
| | [ ] Enable Typewriter Scroll by default | |
| | | |
| | [Reset to Defaults] | |
| | | |
| +-----------------------------------------------+ |
| | | |
| | Appearance Tab: | |
| | Appearance: (o) System | |
| | ( ) Light | |
| | ( ) Dark | |
| | | |
| | Preview: | |
| | +-------------------------------------+ | |
| | | Sample rendered Markdown text | | |
| | | with current font/spacing settings | | |
| | +-------------------------------------+ | |
| | | |
| +-----------------------------------------------+ |
+---------------------------------------------------+
struct EditorPreferencesTab: View {
@Bindable var settings: EditorSettings
var body: some View {
Form {
Picker("Font Family", selection: $settings.fontFamily) {
ForEach(FontCatalog.all) { font in
Text(font.displayName).tag(font.id)
}
}
Stepper("Font Size: \(settings.fontSize, specifier: "%.0f") pt",
value: $settings.fontSize, in: 10...24, step: 1)
HStack {
Text("Line Spacing: \(settings.lineSpacing, specifier: "%.1f")x")
Slider(value: $settings.lineSpacing, in: 1.0...2.0, step: 0.1)
}
Divider()
Toggle("Enable Focus Mode by default", isOn: $settings.defaultFocusMode)
Toggle("Enable Typewriter Scroll by default", isOn: $settings.defaultTypewriterScroll)
Divider()
Button("Reset to Defaults") { settings.reset() }
}
.padding()
}
}
1. ShoechooApp.init()
2. EditorSettings.shared loads from UserDefaults
3. DocumentGroup scene activates
4. NSDocumentController detects no restorable windows
5. Creates new MarkdownDocument (blank)
6. makeWindowControllers() → EditorView with empty sourceText
7. User sees blank editor with toolbar, ready to type
1. User presses Cmd+O
2. NSDocumentController presents NSOpenPanel (filtered to .md files)
3. User selects file → MarkdownDocument.read(from:ofType:)
4. sourceText decoded from file data
5. EditorView created with viewModel bound to document
6. Unit 1 parse pipeline runs → WYSIWYG rendering appears
7. File added to recent documents
1. User opens File > Open Recent
2. NSDocumentController populates menu from recentDocumentURLs
3. User selects a file
4. NSDocumentController opens the document (same as Flow 2, step 3+)
5. If file not found: system alert displayed
1. User has document open, presses Cmd+N
2. If tab bar visible: new tab created in current window
3. If tab bar hidden: new window created
4. User can: Window > Merge All Windows to combine
5. User can: drag tabs to reorder
6. User can: drag tab out to create new window
7. Each tab has independent EditorViewModel
1. User opens Shoe Choo > Settings (Cmd+,)
2. PreferencesView appears
3. User changes Font Family from "SF Mono" to "Menlo"
4. EditorSettings.shared.fontFamily updates (@ Observable)
5. EditorSettings.save() persists to UserDefaults
6. All open EditorView instances observe the change
7. RenderCache invalidated in each EditorViewModel
8. Full re-render with new font in all editors
1. User edits text in document
2. EditorViewModel.textDidChange() updates MarkdownDocument.sourceText
3. NSDocument marks document as edited (dot in close button)
4. After macOS auto-save interval:
5. NSDocument calls data(ofType:) → encodes current sourceText
6. File written to disk
7. Document marked as not edited
8. User continues editing uninterrupted
// In ShoechooApp .commands block
.commands {
// File commands (Cmd+N, Cmd+O, Cmd+S, Cmd+Shift+S)
// are provided by DocumentGroup automatically
// Format commands (from Unit 1, registered here for menu visibility)
CommandGroup(replacing: .textFormatting) {
Button("Bold") { focusedViewModel?.toggleBold() }
.keyboardShortcut("b", modifiers: .command)
Button("Italic") { focusedViewModel?.toggleItalic() }
.keyboardShortcut("i", modifiers: .command)
Button("Link") { focusedViewModel?.insertLink() }
.keyboardShortcut("k", modifiers: .command)
Button("Inline Code") { focusedViewModel?.toggleInlineCode() }
.keyboardShortcut("k", modifiers: [.command, .shift])
}
CommandGroup(after: .textFormatting) {
ForEach(1...6, id: \.self) { level in
Button("Heading \(level)") { focusedViewModel?.setHeading(level: level) }
.keyboardShortcut(KeyEquivalent(Character("\(level)")), modifiers: .command)
}
}
// View commands
CommandGroup(after: .toolbar) {
Button("Toggle Focus Mode") { focusedViewModel?.toggleFocusMode() }
.keyboardShortcut("f", modifiers: [.command, .shift])
Button("Toggle Typewriter Scroll") { focusedViewModel?.toggleTypewriterScroll() }
.keyboardShortcut("t", modifiers: [.command, .shift])
}
}