commit 0c36cc115e6dcd535b8cf320c0b4036b9d60c364 Author: h Date: Sat Sep 19 23:28:18 2026 +0200 feat(*): inkless app for the mxw01 thermal printer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00c29d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# macOS +.DS_Store + +# Editors +.idea/ +.vscode/ +*.swp + +# Xcode +build/ +DerivedData/ +xcuserdata/ +*.xcuserstate +*.xcscmblueprint +*.xcresult +*.ipa +*.dSYM +*.dSYM.zip + +# Swift Package Manager +.build/ +.swiftpm/ + +# Signing: copy Config/Signing.example.xcconfig +Config/Signing.xcconfig + +# Local-only context: product and design notes, agent state, reverse-engineering +.local/ +.impeccable/ +research/ +/PRODUCT.md +/DESIGN.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e58c03a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: local + hooks: + - id: fmt + name: fmt + entry: make fmt + language: system + pass_filenames: false + - id: check + name: check + entry: make check + language: system + pass_filenames: false diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..cd165dc --- /dev/null +++ b/.swiftformat @@ -0,0 +1,8 @@ +--swiftversion 6.0 +--exclude build,research +--maxwidth 140 +--wraparguments before-first +--wrapcollections before-first +--self remove +--commas always +--disable wrapMultilineStatementBraces diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..0b63fbd --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,24 @@ +included: + - Inkless + - InklessTests +line_length: + warning: 140 + ignores_comments: true +identifier_name: + excluded: [x, y, u, v, id, dx, dy] +type_body_length: + warning: 320 +file_length: + warning: 450 +nesting: + type_level: 2 +opt_in_rules: + - empty_count + - first_where + - sorted_first_last + - toggle_bool + - redundant_nil_coalescing +disabled_rules: + - trailing_comma + - opening_brace + - todo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9333590 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +## Code Principles +- **Simplicity**: Write simple, straightforward code +- **Readability**: Make code easy to understand +- **Performance**: Consider performance without sacrificing readability +- **Maintainability**: Write code that's easy to update +- **Reusability**: Create reusable components and functions +- **Less Code = Less Debt**: Minimize code footprint +- **NEVER write comments** - code should be self-documenting +- **Docstrings ONLY if explicitly asked** + +## Checking commands +After writing code, always run: +```shell +make fmt +make check +``` diff --git a/Config/Base.xcconfig b/Config/Base.xcconfig new file mode 100644 index 0000000..441bf58 --- /dev/null +++ b/Config/Base.xcconfig @@ -0,0 +1,2 @@ +BUNDLE_ID_PREFIX = com.example +#include? "Signing.xcconfig" diff --git a/Config/Signing.example.xcconfig b/Config/Signing.example.xcconfig new file mode 100644 index 0000000..8bde387 --- /dev/null +++ b/Config/Signing.example.xcconfig @@ -0,0 +1,2 @@ +DEVELOPMENT_TEAM = ABCDE12345 +BUNDLE_ID_PREFIX = com.yourname diff --git a/Design/printer.svg b/Design/printer.svg new file mode 100644 index 0000000..0435287 --- /dev/null +++ b/Design/printer.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Inkless.xcodeproj/project.pbxproj b/Inkless.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7dbbb0d --- /dev/null +++ b/Inkless.xcodeproj/project.pbxproj @@ -0,0 +1,356 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + A10000000000000000000001 /* Inkless.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Inkless.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000020 /* InklessTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InklessTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000030 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Config/Base.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000002 /* Inkless */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Inkless; + sourceTree = ""; + }; + A10000000000000000000021 /* InklessTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = InklessTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXContainerItemProxy section */ + A10000000000000000000022 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10000000000000000000009 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A10000000000000000000006; + remoteInfo = Inkless; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXTargetDependency section */ + A10000000000000000000023 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A10000000000000000000006 /* Inkless */; + targetProxy = A10000000000000000000022 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000000000000000000024 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000000000000000000004 = { + isa = PBXGroup; + children = ( + A10000000000000000000030 /* Base.xcconfig */, + A10000000000000000000002 /* Inkless */, + A10000000000000000000021 /* InklessTests */, + A10000000000000000000005 /* Products */, + ); + sourceTree = ""; + }; + A10000000000000000000005 /* Products */ = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* Inkless.app */, + A10000000000000000000020 /* InklessTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000000000000000000006 /* Inkless */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1000000000000000000000B /* Build configuration list for PBXNativeTarget "Inkless" */; + buildPhases = ( + A10000000000000000000007 /* Sources */, + A10000000000000000000003 /* Frameworks */, + A10000000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000002 /* Inkless */, + ); + name = Inkless; + productName = Inkless; + productReference = A10000000000000000000001 /* Inkless.app */; + productType = "com.apple.product-type.application"; + }; + A10000000000000000000025 /* InklessTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000028 /* Build configuration list for PBXNativeTarget "InklessTests" */; + buildPhases = ( + A10000000000000000000026 /* Sources */, + A10000000000000000000024 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + A10000000000000000000023 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000021 /* InklessTests */, + ); + name = InklessTests; + productName = InklessTests; + productReference = A10000000000000000000020 /* InklessTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000000000000000000009 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; + TargetAttributes = { + A10000000000000000000006 = { + CreatedOnToolsVersion = 27.0; + }; + A10000000000000000000025 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = A10000000000000000000006; + }; + }; + }; + buildConfigurationList = A1000000000000000000000A /* Build configuration list for PBXProject "Inkless" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A10000000000000000000004; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A10000000000000000000005 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000000000000000000006 /* Inkless */, + A10000000000000000000025 /* InklessTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A10000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A10000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000000000000000000026 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A1000000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A10000000000000000000030 /* Base.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A1000000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A10000000000000000000030 /* Base.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A1000000000000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Inkless; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Inkless uses Bluetooth to connect to your thermal printer."; + INFOPLIST_KEY_NSCameraUsageDescription = "Take a picture and print it right away."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).inkless"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A1000000000000000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Inkless; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Inkless uses Bluetooth to connect to your thermal printer."; + INFOPLIST_KEY_NSCameraUsageDescription = "Take a picture and print it right away."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).inkless"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + A1000000000000000000002A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).inkless.tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Inkless.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Inkless"; + }; + name = Debug; + }; + A1000000000000000000002B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).inkless.tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Inkless.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Inkless"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A1000000000000000000000A /* Build configuration list for PBXProject "Inkless" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000C /* Debug */, + A1000000000000000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1000000000000000000000B /* Build configuration list for PBXNativeTarget "Inkless" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000E /* Debug */, + A1000000000000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A10000000000000000000028 /* Build configuration list for PBXNativeTarget "InklessTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000002A /* Debug */, + A1000000000000000000002B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A10000000000000000000009 /* Project object */; +} diff --git a/Inkless.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Inkless.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Inkless.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Inkless.xcodeproj/xcshareddata/xcschemes/Inkless.xcscheme b/Inkless.xcodeproj/xcshareddata/xcschemes/Inkless.xcscheme new file mode 100644 index 0000000..207191e --- /dev/null +++ b/Inkless.xcodeproj/xcshareddata/xcschemes/Inkless.xcscheme @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Inkless/App/Composers.swift b/Inkless/App/Composers.swift new file mode 100644 index 0000000..11eed58 --- /dev/null +++ b/Inkless/App/Composers.swift @@ -0,0 +1,195 @@ +import Observation +import SwiftUI + +nonisolated enum Look: String, CaseIterable, Identifiable, Sendable { + case photo, soft, graphic, smooth + + var id: String { + rawValue + } + + var title: String { + switch self { + case .photo: "Photo" + case .soft: "Soft" + case .graphic: "Graphic" + case .smooth: "HD" + } + } + + var style: PrintStyle { + switch self { + case .photo: .dots(.atkinson) + case .soft: .dots(.jarvis) + case .graphic: .dots(.sierraLite) + case .smooth: .smooth + } + } + + func settings(brightness: Double, rotation: Int, inverted: Bool) -> RenderSettings { + var tone = ToneAdjustments(brightness: brightness, inverted: inverted) + switch self { + case .photo: + tone.contrast = 0.15 + tone.sharpness = 0.2 + case .soft: + tone.gamma = 0.1 + case .graphic: + tone.contrast = 0.4 + tone.sharpness = 0.45 + case .smooth: + tone.contrast = 0.1 + } + return RenderSettings(style: style, tone: tone, rotation: rotation) + } +} + +@Observable +final class PhotoComposer { + static let proofDots = 216 + + private(set) var source: CGImage? + private(set) var raster: PrintRaster? + private(set) var preview: CGImage? + private(set) var proofs: [PrintStyle: CGImage] = [:] + private(set) var accentHue: Double? + + var look = Look.photo { + didSet { + if look != oldValue { + applyLook() + } + } + } + + var settings = RenderSettings() { + didSet { + if settings != oldValue { + render() + } + } + } + + @ObservationIgnored private var field: GrayField? + @ObservationIgnored private var fieldKey: FieldKey? + @ObservationIgnored private var renderTask: Task? + @ObservationIgnored private var proofTask: Task? + + func load(_ image: CGImage) { + source = image + fieldKey = nil + accentHue = ImageAccent.hue(of: image) + applyLook() + render() + } + + func clear() { + renderTask?.cancel() + proofTask?.cancel() + source = nil + fieldKey = nil + raster = nil + preview = nil + proofs = [:] + accentHue = nil + } + + func applyLook() { + settings = look.settings( + brightness: settings.tone.brightness, rotation: settings.rotation, inverted: settings.tone.inverted + ) + } + + func resetTone() { + settings = RenderSettings(style: settings.style) + } + + private struct Rendering { + let field: GrayField + let raster: PrintRaster + let image: CGImage + } + + private struct FieldKey: Equatable { + let tone: ToneAdjustments + let rotation: Int + } + + private func render() { + guard let source else { return } + let settings = settings + let key = FieldKey(tone: settings.tone, rotation: settings.rotation) + let cached = fieldKey == key ? field : nil + renderTask?.cancel() + renderTask = Task { + guard let result = await Self.rasterise(source, settings, cached), !Task.isCancelled else { return } + raster = result.raster + preview = result.image + guard fieldKey != key else { return } + field = result.field + fieldKey = key + renderProofs(from: result.field) + } + } + + private func renderProofs(from field: GrayField) { + proofTask?.cancel() + proofTask = Task { + try? await Task.sleep(for: .milliseconds(150)) + let crop = field.centreCrop(side: Self.proofDots) + for style in PrintStyle.all { + guard !Task.isCancelled else { return } + proofs[style] = await Self.proof(crop, style) + } + } + } + + @concurrent + private static func rasterise( + _ image: CGImage, _ settings: RenderSettings, _ cached: GrayField? + ) async -> Rendering? { + let prepared = cached ?? RasterPipeline.prepare(image, tone: settings.tone, rotation: settings.rotation) + guard let prepared else { return nil } + let raster = prepared.rasterised(as: settings.style) + guard let rendered = raster.makeImage() else { return nil } + return Rendering(field: prepared, raster: raster, image: rendered) + } + + @concurrent + private static func proof(_ crop: GrayField, _ style: PrintStyle) async -> CGImage? { + crop.rasterised(as: style).makeImage() + } +} + +@Observable +final class TextComposer { + var text = "" { + didSet { render() } + } + + var style = TextStyle() { + didSet { render() } + } + + private(set) var raster: PrintRaster? + private(set) var preview: CGImage? + + @ObservationIgnored private var renderTask: Task? + + private func render() { + let (text, style) = (text, style) + renderTask?.cancel() + renderTask = Task { + let result = await Self.rasterise(text, style) + guard !Task.isCancelled else { return } + raster = result?.raster + preview = result?.image + } + } + + @concurrent + private static func rasterise(_ text: String, _ style: TextStyle) async -> (raster: PrintRaster, image: CGImage)? { + guard let raster = TextRenderer.render(text, style: style), let image = raster.makeImage() else { return nil } + return (raster, image) + } +} diff --git a/Inkless/App/DebugLaunch.swift b/Inkless/App/DebugLaunch.swift new file mode 100644 index 0000000..00d1421 --- /dev/null +++ b/Inkless/App/DebugLaunch.swift @@ -0,0 +1,53 @@ +import ImageIO +import SwiftUI + +enum DebugLaunch { + static func apply(to model: AppModel) { + #if DEBUG + applyContent(to: model, from: .standard) + applyFlags(to: model, from: ProcessInfo.processInfo.arguments) + #endif + } + + #if DEBUG + private static func applyContent(to model: AppModel, from defaults: UserDefaults) { + if let path = defaults.string(forKey: "image"), + let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) { + model.photo.load(image) + } + if let look = defaults.string(forKey: "look").flatMap(Look.init) { + model.photo.look = look + } + if let algorithm = defaults.string(forKey: "algorithm").flatMap(DitherAlgorithm.init) { + model.photo.settings.style = .dots(algorithm) + } + if let text = defaults.string(forKey: "text") { + model.text.text = text.replacingOccurrences(of: "\\n", with: "\n") + model.text.style.checklist = defaults.bool(forKey: "checklist") + } + switch defaults.string(forKey: "section") { + case "text": model.section = .text + case "printer": model.section = .printer + default: break + } + } + + private static func applyFlags(to model: AppModel, from arguments: [String]) { + if arguments.contains("-unpaired") { + model.printer.forgetPrinter() + } else if model.printer.isDemo { + model.printer.connect(.init(id: UUID(), name: "MXW01", rssi: -48)) + } + if arguments.contains("-pairing") { + model.isPairing = true + } + if arguments.contains("-printing") { + Task { + try? await Task.sleep(for: .seconds(2.5)) + model.print() + } + } + } + #endif +} diff --git a/Inkless/App/InklessApp.swift b/Inkless/App/InklessApp.swift new file mode 100644 index 0000000..e190e02 --- /dev/null +++ b/Inkless/App/InklessApp.swift @@ -0,0 +1,59 @@ +import SwiftUI + +@main +struct InklessApp: App { + @State private var model = AppModel() + + var body: some Scene { + WindowGroup { + RootView() + .environment(model) + .environment(model.printer) + } + } +} + +@Observable +final class AppModel { + enum Section: Hashable { case photo, text, printer } + + let printer = PrinterManager() + let photo = PhotoComposer() + let text = TextComposer() + + var section = Section.photo + var isPairing = false + var failure: PrintError? + private(set) var completedJobs = 0 + + var printable: PrintRaster? { + switch section { + case .photo: photo.raster + case .text: text.raster + case .printer: nil + } + } + + init() { + DebugLaunch.apply(to: self) + } + + func print() { + guard let printable else { return } + guard printer.link == .ready else { + if printer.hasRememberedPrinter { + failure = .notConnected + } else { + isPairing = true + } + return + } + printer.print(printable) { [weak self] result in + switch result { + case .success: self?.completedJobs += 1 + case .failure(.cancelled): break + case let .failure(error): self?.failure = error + } + } + } +} diff --git a/Inkless/AppIcon.icon/Assets/body.svg b/Inkless/AppIcon.icon/Assets/body.svg new file mode 100644 index 0000000..08bb596 --- /dev/null +++ b/Inkless/AppIcon.icon/Assets/body.svg @@ -0,0 +1 @@ + diff --git a/Inkless/AppIcon.icon/Assets/paper.svg b/Inkless/AppIcon.icon/Assets/paper.svg new file mode 100644 index 0000000..07d09ee --- /dev/null +++ b/Inkless/AppIcon.icon/Assets/paper.svg @@ -0,0 +1 @@ + diff --git a/Inkless/AppIcon.icon/Assets/ring.svg b/Inkless/AppIcon.icon/Assets/ring.svg new file mode 100644 index 0000000..8273661 --- /dev/null +++ b/Inkless/AppIcon.icon/Assets/ring.svg @@ -0,0 +1 @@ + diff --git a/Inkless/AppIcon.icon/icon.json b/Inkless/AppIcon.icon/icon.json new file mode 100644 index 0000000..1293daa --- /dev/null +++ b/Inkless/AppIcon.icon/icon.json @@ -0,0 +1,83 @@ +{ + "fill-specializations": [ + { + "value": { + "automatic-gradient": "display-p3:0.38000,0.33000,0.98000,1.00000" + } + }, + { + "appearance": "dark", + "value": { + "automatic-gradient": "display-p3:0.13000,0.11000,0.36000,1.00000" + } + } + ], + "groups": [ + { + "layers": [ + { + "blend-mode": "normal", + "fill": "automatic", + "glass": true, + "image-name": "paper.svg", + "name": "paper", + "opacity": 1 + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.4 + }, + "specular": true, + "translucency": { + "enabled": false, + "value": 0.5 + } + }, + { + "layers": [ + { + "blend-mode": "normal", + "fill": "automatic", + "glass": true, + "image-name": "ring.svg", + "name": "ring", + "opacity": 1 + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.35 + }, + "specular": true, + "translucency": { + "enabled": false, + "value": 0.5 + } + }, + { + "layers": [ + { + "blend-mode": "normal", + "fill": "automatic", + "glass": true, + "image-name": "body.svg", + "name": "body", + "opacity": 1 + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "specular": true, + "translucency": { + "enabled": false, + "value": 0.5 + } + } + ], + "supported-platforms": { + "squares": "shared" + } +} \ No newline at end of file diff --git a/Inkless/Assets.xcassets/Contents.json b/Inkless/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/Inkless/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/Inkless/Assets.xcassets/Printer.imageset/Contents.json b/Inkless/Assets.xcassets/Printer.imageset/Contents.json new file mode 100644 index 0000000..d865203 --- /dev/null +++ b/Inkless/Assets.xcassets/Printer.imageset/Contents.json @@ -0,0 +1 @@ +{ "images" : [ { "idiom" : "universal", "filename" : "printer@1x.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "printer@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "printer@3x.png", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } diff --git a/Inkless/Assets.xcassets/Printer.imageset/printer@1x.png b/Inkless/Assets.xcassets/Printer.imageset/printer@1x.png new file mode 100644 index 0000000..a7bad48 Binary files /dev/null and b/Inkless/Assets.xcassets/Printer.imageset/printer@1x.png differ diff --git a/Inkless/Assets.xcassets/Printer.imageset/printer@2x.png b/Inkless/Assets.xcassets/Printer.imageset/printer@2x.png new file mode 100644 index 0000000..3cf289b Binary files /dev/null and b/Inkless/Assets.xcassets/Printer.imageset/printer@2x.png differ diff --git a/Inkless/Assets.xcassets/Printer.imageset/printer@3x.png b/Inkless/Assets.xcassets/Printer.imageset/printer@3x.png new file mode 100644 index 0000000..0cceb25 Binary files /dev/null and b/Inkless/Assets.xcassets/Printer.imageset/printer@3x.png differ diff --git a/Inkless/Imaging/Dither.swift b/Inkless/Imaging/Dither.swift new file mode 100644 index 0000000..57f1c52 --- /dev/null +++ b/Inkless/Imaging/Dither.swift @@ -0,0 +1,281 @@ +import Foundation + +nonisolated enum DitherAlgorithm: String, CaseIterable, Identifiable, Sendable, Codable { + case atkinson, floydSteinberg, burkes, stucki, jarvis, sierra, sierraTwoRow, sierraLite + case riemersma, blueNoise, bayer8, bayer4, halftone, lines, noise, threshold + + var id: String { + rawValue + } + + var title: String { + switch self { + case .atkinson: "Atkinson" + case .floydSteinberg: "Floyd" + case .burkes: "Burkes" + case .stucki: "Stucki" + case .jarvis: "Jarvis" + case .sierra: "Sierra" + case .sierraTwoRow: "Sierra 2" + case .sierraLite: "Sierra Lite" + case .riemersma: "Riemersma" + case .blueNoise: "Blue Noise" + case .bayer8: "Bayer 8" + case .bayer4: "Bayer 4" + case .halftone: "Halftone" + case .lines: "Lines" + case .noise: "Noise" + case .threshold: "Threshold" + } + } + + var summary: String { + switch self { + case .atkinson: "Crisp and contrasty, the original Macintosh look. Great for faces." + case .floydSteinberg: "The classic: the most tonal range, soft grain." + case .burkes: "A faster Stucki. Clean midtones with a little extra bite." + case .stucki: "Sharper than Floyd, fewer worm trails in the highlights." + case .jarvis: "The smoothest grain of all, slightly softens fine detail." + case .sierra: "Balanced three-row diffusion, between Jarvis and Floyd." + case .sierraTwoRow: "Lighter Sierra: nearly as smooth, noticeably crisper." + case .sierraLite: "Minimal diffusion. Tidy on graphics and screenshots." + case .riemersma: "Error follows a Hilbert curve: organic grain with no direction." + case .blueNoise: "Film-like scatter without patterns or worms." + case .bayer8: "Ordered 8×8 grid. Retro texture, rock steady on gradients." + case .bayer4: "Coarser 4×4 grid. Chunky, pixel-art character." + case .halftone: "Newspaper dots at 45°. A poster look." + case .lines: "Engraved horizontal lines that swell with darkness." + case .noise: "Random stipple, like a photocopy of a photocopy." + case .threshold: "No midtones at all: text, screenshots, line art." + } + } + + func apply(to gray: [Float], width: Int, height: Int) -> [UInt8] { + if let kernel { + return Self.diffuse(gray, width, height, kernel) + } + if let orderedThreshold { + return Self.ordered(gray, width, height, orderedThreshold) + } + return self == .riemersma ? Self.riemersma(gray, width, height) : gray.map { $0 < 0.5 ? 1 : 0 } + } + + private var kernel: [Tap]? { + switch self { + case .atkinson: Kernels.atkinson + case .floydSteinberg: Kernels.floyd + case .burkes: Kernels.burkes + case .stucki: Kernels.stucki + case .jarvis: Kernels.jarvis + case .sierra: Kernels.sierra + case .sierraTwoRow: Kernels.sierraTwoRow + case .sierraLite: Kernels.sierraLite + default: nil + } + } + + private var orderedThreshold: ((Int, Int) -> Float)? { + switch self { + case .blueNoise: Thresholds.interleavedGradient + case .bayer8: { x, y in Thresholds.bayer8[(y & 7) * 8 + (x & 7)] } + case .bayer4: { x, y in Thresholds.bayer4[(y & 3) * 4 + (x & 3)] } + case .halftone: Thresholds.halftone + case .lines: Thresholds.lines + case .noise: Thresholds.whiteNoise + default: nil + } + } + + private static func diffuse(_ gray: [Float], _ width: Int, _ height: Int, _ kernel: [Tap]) -> [UInt8] { + var buffer = gray + var out = [UInt8](repeating: 0, count: width * height) + buffer.withUnsafeMutableBufferPointer { buf in + out.withUnsafeMutableBufferPointer { dst in + for y in 0 ..< height { + let reversed = y & 1 == 1 + let direction = reversed ? -1 : 1 + var x = reversed ? width - 1 : 0 + for _ in 0 ..< width { + let index = y * width + x + let old = buf[index] + let black = old < 0.5 + dst[index] = black ? 1 : 0 + let error = old - (black ? 0 : 1) + for tap in kernel { + let neighbourX = x + tap.dx * direction + let neighbourY = y + tap.dy + if neighbourX >= 0, neighbourX < width, neighbourY < height { + buf[neighbourY * width + neighbourX] += error * tap.weight + } + } + x += direction + } + } + } + } + return out + } + + private static func ordered( + _ gray: [Float], _ width: Int, _ height: Int, _ threshold: (Int, Int) -> Float + ) -> [UInt8] { + var out = [UInt8](repeating: 0, count: width * height) + for y in 0 ..< height { + for x in 0 ..< width where gray[y * width + x] < threshold(x, y) { + out[y * width + x] = 1 + } + } + return out + } + + private static func riemersma(_ gray: [Float], _ width: Int, _ height: Int) -> [UInt8] { + var out = [UInt8](repeating: 0, count: width * height) + let historyLength = 16 + let ratio = Float(1.0 / 16.0) + let weights = (0 ..< historyLength).map { index in + pow(ratio, Float(historyLength - 1 - index) / Float(historyLength - 1)) + } + let weightSum = weights.reduce(0, +) + var history = [Float](repeating: 0, count: historyLength) + var order = 1 + while order < width { + order <<= 1 + } + + for band in stride(from: 0, to: height, by: order) { + for distance in 0 ..< order * order { + let (x, bandY) = hilbertPoint(order: order, distance: distance) + let y = band + bandY + guard x < width, y < height else { continue } + var carried: Float = 0 + for index in 0 ..< historyLength { + carried += history[index] * weights[index] + } + let value = gray[y * width + x] + carried / weightSum + let black = value < 0.5 + out[y * width + x] = black ? 1 : 0 + history.removeFirst() + history.append(value - (black ? 0 : 1)) + } + } + return out + } + + private static func hilbertPoint(order: Int, distance: Int) -> (Int, Int) { + var (x, y, remaining, scale) = (0, 0, distance, 1) + while scale < order { + let quadrantX = 1 & (remaining / 2) + let quadrantY = 1 & (remaining ^ quadrantX) + if quadrantY == 0 { + if quadrantX == 1 { + x = scale - 1 - x + y = scale - 1 - y + } + swap(&x, &y) + } + x += scale * quadrantX + y += scale * quadrantY + remaining /= 4 + scale *= 2 + } + return (x, y) + } +} + +private nonisolated struct Tap { + let dx: Int + let dy: Int + let weight: Float + + init(_ dx: Int, _ dy: Int, _ numerator: Float, _ denominator: Float) { + self.dx = dx + self.dy = dy + weight = numerator / denominator + } +} + +private nonisolated enum Kernels { + static let floyd = [Tap(1, 0, 7, 16), Tap(-1, 1, 3, 16), Tap(0, 1, 5, 16), Tap(1, 1, 1, 16)] + + static let atkinson = [ + Tap(1, 0, 1, 8), Tap(2, 0, 1, 8), + Tap(-1, 1, 1, 8), Tap(0, 1, 1, 8), Tap(1, 1, 1, 8), + Tap(0, 2, 1, 8), + ] + + static let burkes = [ + Tap(1, 0, 8, 32), Tap(2, 0, 4, 32), + Tap(-2, 1, 2, 32), Tap(-1, 1, 4, 32), Tap(0, 1, 8, 32), Tap(1, 1, 4, 32), Tap(2, 1, 2, 32), + ] + + static let stucki = [ + Tap(1, 0, 8, 42), Tap(2, 0, 4, 42), + Tap(-2, 1, 2, 42), Tap(-1, 1, 4, 42), Tap(0, 1, 8, 42), Tap(1, 1, 4, 42), Tap(2, 1, 2, 42), + Tap(-2, 2, 1, 42), Tap(-1, 2, 2, 42), Tap(0, 2, 4, 42), Tap(1, 2, 2, 42), Tap(2, 2, 1, 42), + ] + + static let jarvis = [ + Tap(1, 0, 7, 48), Tap(2, 0, 5, 48), + Tap(-2, 1, 3, 48), Tap(-1, 1, 5, 48), Tap(0, 1, 7, 48), Tap(1, 1, 5, 48), Tap(2, 1, 3, 48), + Tap(-2, 2, 1, 48), Tap(-1, 2, 3, 48), Tap(0, 2, 5, 48), Tap(1, 2, 3, 48), Tap(2, 2, 1, 48), + ] + + static let sierra = [ + Tap(1, 0, 5, 32), Tap(2, 0, 3, 32), + Tap(-2, 1, 2, 32), Tap(-1, 1, 4, 32), Tap(0, 1, 5, 32), Tap(1, 1, 4, 32), Tap(2, 1, 2, 32), + Tap(-1, 2, 2, 32), Tap(0, 2, 3, 32), Tap(1, 2, 2, 32), + ] + + static let sierraTwoRow = [ + Tap(1, 0, 4, 16), Tap(2, 0, 3, 16), + Tap(-2, 1, 1, 16), Tap(-1, 1, 2, 16), Tap(0, 1, 3, 16), Tap(1, 1, 2, 16), Tap(2, 1, 1, 16), + ] + + static let sierraLite = [Tap(1, 0, 2, 4), Tap(-1, 1, 1, 4), Tap(0, 1, 1, 4)] +} + +private nonisolated enum Thresholds { + static let bayer8: [Float] = [ + 0, 32, 8, 40, 2, 34, 10, 42, + 48, 16, 56, 24, 50, 18, 58, 26, + 12, 44, 4, 36, 14, 46, 6, 38, + 60, 28, 52, 20, 62, 30, 54, 22, + 3, 35, 11, 43, 1, 33, 9, 41, + 51, 19, 59, 27, 49, 17, 57, 25, + 15, 47, 7, 39, 13, 45, 5, 37, + 63, 31, 55, 23, 61, 29, 53, 21, + ].map { (Float($0) + 0.5) / 64 } + + static let bayer4: [Float] = [ + 0, 8, 2, 10, + 12, 4, 14, 6, + 3, 11, 1, 9, + 15, 7, 13, 5, + ].map { (Float($0) + 0.5) / 16 } + + static func halftone(_ x: Int, _ y: Int) -> Float { + let cell: Float = 6 + let u = (Float(x) + Float(y)) / cell * .pi + let v = (Float(x) - Float(y)) / cell * .pi + return 0.5 + 0.23 * (cos(u) + cos(v)) + } + + static func lines(_: Int, _ y: Int) -> Float { + let period = 6 + let position = Float(y % period) / Float(period) + return 0.08 + 0.84 * abs(position * 2 - 1) + } + + static func interleavedGradient(_ x: Int, _ y: Int) -> Float { + let value = 52.982_918_9 * (0.067_110_56 * Float(x) + 0.005_837_15 * Float(y)).truncatingRemainder(dividingBy: 1) + return 0.04 + 0.92 * value.truncatingRemainder(dividingBy: 1) + } + + static func whiteNoise(_ x: Int, _ y: Int) -> Float { + var state = UInt32(truncatingIfNeeded: x &* 73_856_093 ^ y &* 19_349_663) + state ^= state << 13 + state ^= state >> 17 + state ^= state << 5 + return 0.05 + 0.9 * Float(state & 0xFFFF) / 65535 + } +} diff --git a/Inkless/Imaging/ImageAccent.swift b/Inkless/Imaging/ImageAccent.swift new file mode 100644 index 0000000..92e5f1a --- /dev/null +++ b/Inkless/Imaging/ImageAccent.swift @@ -0,0 +1,75 @@ +import CoreGraphics +import SwiftUI +import UIKit + +nonisolated enum ImageAccent { + private static let sampleSide = 24 + private static let hueBuckets = 12 + private static let maximumLuminanceOnLight = 0.175 + private static let maximumLuminanceOnDark = 0.24 + + static func hue(of image: CGImage) -> Double? { + var pixels = [UInt8](repeating: 0, count: sampleSide * sampleSide * 4) + let drawn: Bool = pixels.withUnsafeMutableBytes { raw in + guard let context = CGContext( + data: raw.baseAddress, width: sampleSide, height: sampleSide, + bitsPerComponent: 8, bytesPerRow: sampleSide * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return false } + context.interpolationQuality = .medium + context.draw(image, in: CGRect(x: 0, y: 0, width: sampleSide, height: sampleSide)) + return true + } + guard drawn else { return nil } + + var weights = [Double](repeating: 0, count: hueBuckets) + var hues = [Double](repeating: 0, count: hueBuckets) + for index in stride(from: 0, to: pixels.count, by: 4) { + let tone = hsb(pixels[index], pixels[index + 1], pixels[index + 2]) + let weight = tone.saturation * tone.saturation * tone.brightness + let bucket = min(hueBuckets - 1, Int(tone.hue * Double(hueBuckets))) + weights[bucket] += weight + hues[bucket] += tone.hue * weight + } + guard let best = weights.indices.max(by: { weights[$0] < weights[$1] }), weights[best] > 4 else { return nil } + return hues[best] / weights[best] + } + + static func color(hue: Double, dark: Bool) -> Color { + Color(uiColor: legible(hue: hue, limit: dark ? maximumLuminanceOnDark : maximumLuminanceOnLight)) + } + + private static func legible(hue: Double, limit: Double) -> UIColor { + var brightness = 0.95 + while brightness > 0.3, luminance(hue: hue, saturation: 0.8, brightness: brightness) > limit { + brightness -= 0.03 + } + return UIColor(hue: hue, saturation: 0.8, brightness: brightness, alpha: 1) + } + + private static func luminance(hue: Double, saturation: Double, brightness: Double) -> Double { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1) + .getRed(&red, green: &green, blue: &blue, alpha: nil) + let linear = [red, green, blue].map { $0 <= 0.04045 ? $0 / 12.92 : pow(($0 + 0.055) / 1.055, 2.4) } + return Double(0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]) + } + + private struct HSB { + let hue: Double + let saturation: Double + let brightness: Double + } + + private static func hsb(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> HSB { + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + UIColor(red: CGFloat(red) / 255, green: CGFloat(green) / 255, blue: CGFloat(blue) / 255, alpha: 1) + .getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + return HSB(hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness)) + } +} diff --git a/Inkless/Imaging/Raster.swift b/Inkless/Imaging/Raster.swift new file mode 100644 index 0000000..2d2391e --- /dev/null +++ b/Inkless/Imaging/Raster.swift @@ -0,0 +1,134 @@ +import CoreGraphics +import Foundation + +nonisolated enum PrintHead { + static let dots = 384 + static let dotsPerMillimetre = 8.0 +} + +nonisolated struct DotBitmap: Sendable, Equatable { + let width: Int + let height: Int + var dots: [UInt8] + + init(width: Int, height: Int, dots: [UInt8]) { + precondition(dots.count == width * height) + self.width = width + self.height = height + self.dots = dots + } + + static func blank(rows: Int) -> DotBitmap { + DotBitmap(width: PrintHead.dots, height: rows, dots: .init(repeating: 0, count: PrintHead.dots * rows)) + } + + var blackRatio: Double { + guard !dots.isEmpty else { return 0 } + return Double(dots.reduce(into: 0) { $0 += Int($1) }) / Double(dots.count) + } + + func packedRows() -> Data { + let stride = (width + 7) / 8 + var out = Data(count: stride * height) + out.withUnsafeMutableBytes { raw in + let bytes = raw.bindMemory(to: UInt8.self) + for y in 0 ..< height { + for x in 0 ..< width where dots[y * width + x] != 0 { + bytes[y * stride + (x >> 3)] |= 1 << UInt8(x & 7) + } + } + } + return out + } +} + +nonisolated struct GrayBitmap: Sendable, Equatable { + let width: Int + let height: Int + var luma: [UInt8] + + var blackRatio: Double { + guard !luma.isEmpty else { return 0 } + return 1 - Double(luma.reduce(into: 0) { $0 += Int($1) }) / Double(luma.count * 255) + } + + static func darkness(_ luma: UInt8) -> UInt8 { + 15 - luma / 16 + } + + func packedNibbles() -> Data { + var out = Data(capacity: (luma.count + 1) / 2) + var index = 0 + while index < luma.count { + let high = Self.darkness(luma[index]) + let low = index + 1 < luma.count ? Self.darkness(luma[index + 1]) : 0 + out.append(high << 4 | low) + index += 2 + } + return out + } + + func packedBytes() -> Data { + Data(luma) + } +} + +nonisolated enum PrintRaster: Sendable, Equatable { + case dots(DotBitmap) + case gray(GrayBitmap) + + var width: Int { + switch self { + case let .dots(bitmap): bitmap.width + case let .gray(bitmap): bitmap.width + } + } + + var height: Int { + switch self { + case let .dots(bitmap): bitmap.height + case let .gray(bitmap): bitmap.height + } + } + + var blackRatio: Double { + switch self { + case let .dots(bitmap): bitmap.blackRatio + case let .gray(bitmap): bitmap.blackRatio + } + } + + var lengthInMillimetres: Double { + Double(height) / PrintHead.dotsPerMillimetre + } + + func padded(toMinimumHeight minimum: Int) -> PrintRaster { + guard height < minimum else { return self } + let missing = (minimum - height) * width + switch self { + case let .dots(bitmap): + let dots = bitmap.dots + [UInt8](repeating: 0, count: missing) + return .dots(DotBitmap(width: width, height: minimum, dots: dots)) + case let .gray(bitmap): + let luma = bitmap.luma + [UInt8](repeating: 255, count: missing) + return .gray(GrayBitmap(width: width, height: minimum, luma: luma)) + } + } + + func makeImage(cropping rect: CGRect? = nil) -> CGImage? { + let pixels: [UInt8] = switch self { + case let .dots(bitmap): bitmap.dots.map { $0 == 0 ? 255 : 0 } + case let .gray(bitmap): bitmap.luma.map { 255 - GrayBitmap.darkness($0) * 17 } + } + guard let provider = CGDataProvider(data: Data(pixels) as CFData) else { return nil } + let image = CGImage( + width: width, height: height, + bitsPerComponent: 8, bitsPerPixel: 8, bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue), + provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent + ) + guard let rect else { return image } + return image?.cropping(to: rect) + } +} diff --git a/Inkless/Imaging/RasterPipeline.swift b/Inkless/Imaging/RasterPipeline.swift new file mode 100644 index 0000000..31a7d16 --- /dev/null +++ b/Inkless/Imaging/RasterPipeline.swift @@ -0,0 +1,151 @@ +import CoreGraphics +import Foundation + +nonisolated struct ToneAdjustments: Sendable, Equatable, Codable { + var brightness: Double = 0 + var contrast: Double = 0 + var gamma: Double = 0 + var sharpness: Double = 0 + var inverted = false + + var isNeutral: Bool { + self == ToneAdjustments() + } +} + +nonisolated enum PrintStyle: Hashable, Identifiable, Sendable { + case smooth + case dots(DitherAlgorithm) + + static let all: [PrintStyle] = [.smooth] + DitherAlgorithm.allCases.map(PrintStyle.dots) + + var id: String { + switch self { + case .smooth: "smooth" + case let .dots(algorithm): algorithm.rawValue + } + } + + var title: String { + switch self { + case .smooth: "HD" + case let .dots(algorithm): algorithm.title + } + } + + var summary: String { + switch self { + case .smooth: "True grayscale, no dots. The head makes several passes, so it prints about four times slower." + case let .dots(algorithm): algorithm.summary + } + } +} + +nonisolated struct RenderSettings: Sendable, Equatable { + var style = PrintStyle.dots(.atkinson) + var tone = ToneAdjustments() + var rotation = 0 +} + +nonisolated struct GrayField: Sendable { + let width: Int + let height: Int + let values: [Float] + + func centreCrop(side: Int) -> GrayField { + let cropWidth = min(side, width) + let cropHeight = min(side, height) + let originX = (width - cropWidth) / 2 + let originY = (height - cropHeight) / 2 + var out = [Float]() + out.reserveCapacity(cropWidth * cropHeight) + for y in originY ..< originY + cropHeight { + out.append(contentsOf: values[(y * width + originX) ..< (y * width + originX + cropWidth)]) + } + return GrayField(width: cropWidth, height: cropHeight, values: out) + } + + func rasterised(as style: PrintStyle) -> PrintRaster { + switch style { + case let .dots(algorithm): + .dots(DotBitmap(width: width, height: height, dots: algorithm.apply(to: values, width: width, height: height))) + case .smooth: + .gray(GrayBitmap(width: width, height: height, luma: values.map { UInt8((min(max($0, 0), 1) * 255).rounded()) })) + } + } +} + +nonisolated enum RasterPipeline { + static let maximumRows = 16000 + + static func render(_ image: CGImage, settings: RenderSettings, width: Int = PrintHead.dots) -> PrintRaster? { + prepare(image, tone: settings.tone, rotation: settings.rotation, width: width)?.rasterised(as: settings.style) + } + + static func prepare(_ image: CGImage, tone: ToneAdjustments, rotation: Int, width: Int = PrintHead.dots) -> GrayField? { + guard let scaled = grayscale(image, width: width, rotation: rotation) else { return nil } + var gray = scaled.gray + applyTone(&gray, width: width, height: scaled.height, tone: tone) + return GrayField(width: width, height: scaled.height, values: gray) + } + + private static func grayscale(_ image: CGImage, width: Int, rotation: Int) -> (gray: [Float], height: Int)? { + let turns = ((rotation % 4) + 4) % 4 + let sideways = turns % 2 == 1 + let sourceWidth = CGFloat(sideways ? image.height : image.width) + let sourceHeight = CGFloat(sideways ? image.width : image.height) + guard sourceWidth > 0, sourceHeight > 0 else { return nil } + let height = min(max(1, Int((CGFloat(width) * sourceHeight / sourceWidth).rounded())), maximumRows) + + var pixels = [UInt8](repeating: 255, count: width * height) + let drawn: Bool = pixels.withUnsafeMutableBytes { raw in + guard let context = CGContext( + data: raw.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { return false } + context.interpolationQuality = .high + context.setFillColor(gray: 1, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.translateBy(x: CGFloat(width) / 2, y: CGFloat(height) / 2) + context.rotate(by: -CGFloat(turns) * .pi / 2) + let drawWidth = sideways ? CGFloat(height) : CGFloat(width) + let drawHeight = sideways ? CGFloat(width) : CGFloat(height) + let target = CGRect(x: -drawWidth / 2, y: -drawHeight / 2, width: drawWidth, height: drawHeight) + context.draw(image, in: target) + return true + } + guard drawn else { return nil } + return (pixels.map { Float($0) / 255 }, height) + } + + private static func applyTone(_ gray: inout [Float], width: Int, height: Int, tone: ToneAdjustments) { + if tone.sharpness > 0 { + sharpen(&gray, width: width, height: height, amount: Float(tone.sharpness)) + } + let brightness = Float(tone.brightness) * 0.5 + let slope = Float(pow(3.0, tone.contrast)) + let exponent = Float(pow(2.0, -tone.gamma)) + for index in gray.indices { + var value = min(max((gray[index] - 0.5) * slope + 0.5 + brightness, 0), 1) + if exponent != 1 { + value = pow(value, exponent) + } + gray[index] = tone.inverted ? 1 - value : value + } + } + + private static func sharpen(_ gray: inout [Float], width: Int, height: Int, amount: Float) { + guard width > 2, height > 2 else { return } + let source = gray + let strength = amount * 1.5 + for y in 1 ..< (height - 1) { + for x in 1 ..< (width - 1) { + let index = y * width + x + let neighbours = source[index - 1] + source[index + 1] + source[index - width] + source[index + width] + gray[index] = source[index] + strength * (4 * source[index] - neighbours) + } + } + } +} diff --git a/Inkless/Imaging/TextRenderer.swift b/Inkless/Imaging/TextRenderer.swift new file mode 100644 index 0000000..65e5e1d --- /dev/null +++ b/Inkless/Imaging/TextRenderer.swift @@ -0,0 +1,87 @@ +import SwiftUI +import UIKit + +nonisolated struct TextStyle: Sendable, Equatable { + enum Face: String, CaseIterable, Identifiable, Sendable { + case standard, rounded, serif, mono + + var id: String { + rawValue + } + + var title: String { + switch self { + case .standard: "Default" + case .rounded: "Rounded" + case .serif: "Serif" + case .mono: "Mono" + } + } + + var design: UIFontDescriptor.SystemDesign { + switch self { + case .standard: .default + case .rounded: .rounded + case .serif: .serif + case .mono: .monospaced + } + } + } + + var face = Face.standard + var size = 36.0 + var bold = true + var alignment = NSTextAlignment.left + var checklist = false +} + +nonisolated enum TextRenderer { + private static let margin: CGFloat = 8 + private static let checkbox = "☐ " + + static func render(_ text: String, style: TextStyle, width: Int = PrintHead.dots) -> PrintRaster? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let attributed = attributedString(trimmed, style: style) + let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading] + let column = CGFloat(width) - margin * 2 + let bounds = attributed.boundingRect( + with: CGSize(width: column, height: .greatestFiniteMagnitude), options: options, context: nil + ) + let height = Int(ceil(bounds.height) + margin * 2) + + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = true + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) + let image = renderer.image { context in + UIColor.white.setFill() + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let frame = CGRect(x: margin, y: margin, width: column, height: bounds.height) + attributed.draw(with: frame, options: options, context: nil) + } + guard let cgImage = image.cgImage else { return nil } + return RasterPipeline.render(cgImage, settings: RenderSettings(style: .dots(.threshold)), width: width) + } + + private static func attributedString(_ text: String, style: TextStyle) -> NSAttributedString { + let base = UIFont.systemFont(ofSize: style.size, weight: style.bold ? .bold : .regular) + let font = base.fontDescriptor.withDesign(style.face.design).map { UIFont(descriptor: $0, size: style.size) } ?? base + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = style.alignment + paragraph.lineSpacing = style.size * 0.12 + var body = text + if style.checklist { + paragraph.headIndent = (checkbox as NSString).size(withAttributes: [.font: font]).width + paragraph.paragraphSpacing = style.size * 0.3 + body = text.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces).isEmpty ? String($0) : checkbox + $0 } + .joined(separator: "\n") + } + return NSAttributedString(string: body, attributes: [ + .font: font, .foregroundColor: UIColor.black, .paragraphStyle: paragraph, + ]) + } +} diff --git a/Inkless/Printer/MXWProtocol.swift b/Inkless/Printer/MXWProtocol.swift new file mode 100644 index 0000000..4bd2dc9 --- /dev/null +++ b/Inkless/Printer/MXWProtocol.swift @@ -0,0 +1,233 @@ +import CoreBluetooth +import CryptoKit +import Foundation + +nonisolated enum MXW { + @MainActor static let service = CBUUID(string: "AE30") + @MainActor static let commandCharacteristic = CBUUID(string: "AE01") + @MainActor static let notifyCharacteristic = CBUUID(string: "AE02") + @MainActor static let dataCharacteristic = CBUUID(string: "AE03") + + static let magic: [UInt8] = [0x22, 0x21] + static let minimumRows = 90 + + enum Command: UInt8 { + case status = 0xA1 + case density = 0xA2 + case startPrint = 0xA9 + case printComplete = 0xAA + case cancel = 0xAC + case flush = 0xAD + case flowControl = 0xAE + case version = 0xB1 + case challenge = 0xB3 + } + + enum PrintMode: UInt8 { + case dots = 0x00 + case gray = 0x02 + } + + enum HeadType: Sendable, Equatable { case highVoltage, lowVoltage, unknown } + + static func crc8(_ bytes: some Sequence) -> UInt8 { + var crc: UInt8 = 0 + for byte in bytes { + crc ^= byte + for _ in 0 ..< 8 { + crc = crc & 0x80 != 0 ? (crc << 1) ^ 0x07 : crc << 1 + } + } + return crc + } + + static func frame(_ command: Command, _ payload: [UInt8]) -> Data { + let header = magic + [command.rawValue, 0x00, UInt8(payload.count & 0xFF), UInt8(payload.count >> 8)] + return Data(header + payload + [crc8(payload), 0xFF]) + } + + private static func vendorFrame(_ command: Command, _ payload: [UInt8]) -> Data { + Data(magic + [command.rawValue, 0x00, UInt8(payload.count), 0x00] + payload + [0x00, 0x00]) + } + + static func statusRequest() -> Data { + frame(.status, [0x00]) + } + + static func versionRequest() -> Data { + frame(.version, [0x00]) + } + + static func cancel() -> Data { + frame(.cancel, [0x00]) + } + + static func flush() -> Data { + vendorFrame(.flush, [0x00]) + } + + static func density(_ value: Int) -> Data { + frame(.density, [UInt8(min(max(value, 0), 100))]) + } + + static func startPrint(rows: Int, mode: PrintMode) -> Data { + vendorFrame(.startPrint, [UInt8(rows & 0xFF), UInt8((rows >> 8) & 0xFF), 0x30, mode.rawValue]) + } + + static func usesFourBitGray(firmware: String?) -> Bool { + guard let firmware, firmware.count >= 7 else { return true } + let parts = firmware.dropFirst(4).prefix(3).split(separator: ".").map { Int($0) } + guard parts.count == 2, let major = parts[0], let minor = parts[1] else { return true } + return major > 1 && minor > 0 + } + + static func compensatedDensity( + requested: Int, temperature: Int, blackRatio: Double, head: HeadType, mode: PrintMode + ) -> Int { + if mode == .gray { + guard temperature >= 50 else { return requested } + let low = head == .lowVoltage + let limit = switch temperature { + case ..<55: low ? 80 : 85 + case ..<60: low ? 75 : 80 + case ..<65: low ? 65 : 75 + case ..<70: low ? 60 : 65 + default: 56 + } + return min(requested, limit) + } + guard temperature >= 60 else { return requested } + let bucket = blackRatio <= 0.4 ? 0 : blackRatio < 0.5 ? 1 : blackRatio < 0.7 ? 2 : 3 + let row = temperature < 65 ? 0 : temperature < 70 ? 1 : 2 + let table = head == .lowVoltage + ? [[60, 50, 50, 30], [50, 40, 40, 20], [40, 30, 30, 10]] + : [[48, 15, 15, 10], [36, 9, 5, 5], [22, 5, 3, 3]] + return min(requested, table[row][bucket]) + } + + struct Status: Sendable, Equatable { + enum Activity: UInt8, Sendable { case idle = 0, printing, feeding, retracting } + enum Fault: Sendable, Equatable { case noPaper, overheated, lowBattery, unknown(UInt8) } + + var activity: Activity + var battery: Int + var temperature: Int + var fault: Fault? + } + + enum Notification: Sendable, Equatable { + case status(Status) + case printAccepted + case printRejected(UInt8) + case printComplete + case flowPause + case flowResume + case version(String, HeadType) + case challenge([UInt8]) + case other(UInt8) + + var isStatus: Bool { + if case .status = self { + true + } else { + false + } + } + + var isPrintReply: Bool { + switch self { + case .printAccepted, .printRejected: true + default: false + } + } + } + + static func parse(_ data: Data) -> Notification? { + let bytes = [UInt8](data) + guard bytes.count >= 6, bytes[0] == magic[0], bytes[1] == magic[1] else { return nil } + let length = Int(bytes[4]) | Int(bytes[5]) << 8 + let payload = Array(bytes.dropFirst(6).prefix(length)) + + switch Command(rawValue: bytes[2]) { + case .status: + return parseStatus(payload) ?? .other(bytes[2]) + case .startPrint: + let code = payload.first ?? 0 + return code == 0 ? .printAccepted : .printRejected(code) + case .printComplete: + return .printComplete + case .flowControl: + let flagged = payload.first == 0x10 + let bareForm = bytes[3] == 0x00 + return (bareForm ? !flagged : flagged) ? .flowPause : .flowResume + case .version: + return parseVersion(payload) + case .challenge: + return .challenge(Array(payload.prefix(10))) + default: + return .other(bytes[2]) + } + } + + private static func parseVersion(_ payload: [UInt8]) -> Notification { + let text = String(bytes: payload.prefix(9).prefix { $0 != 0 }, encoding: .utf8) ?? "" + let head: HeadType = switch text.last { + case "2": .highVoltage + case "1": .lowVoltage + default: .unknown + } + return .version(text, head) + } + + private static func parseStatus(_ payload: [UInt8]) -> Notification? { + guard payload.count >= 8 else { return nil } + var fault: Status.Fault? + if payload[6] != 0 { + fault = switch payload[7] { + case 0x01, 0x09: .noPaper + case 0x04: .overheated + case 0x08: .lowBattery + default: .unknown(payload[7]) + } + } + return .status(Status( + activity: .init(rawValue: payload[0]) ?? .idle, + battery: Int(payload[3]), temperature: Int(payload[4]), fault: fault + )) + } + + static func challengeResponse(to challenge: [UInt8], now: Date = .now) -> Data? { + guard challenge.count == 10 else { return nil } + let millis = String(Int(now.timeIntervalSince1970 * 1000)).suffix(4) + guard let high = UInt8(millis.prefix(2)), let low = UInt8(millis.suffix(2)) else { return nil } + let highHex = String(format: "%02x", high) + let lowHex = String(format: "%02x", low) + + let mask: [UInt8] = [0xA9, low, 0xD3, 0x03, 0x78, 0xB6, 0x15, high, 0xEA, 0x82] + let key = "93\(highHex)e8ae5e93d79683dcaf9e\(lowHex)3ede35" + let message = zip(challenge, mask).map { String(format: "%02X", $0 ^ $1) }.joined() + + let mac = HMAC.authenticationCode(for: Data(message.utf8), using: SymmetricKey(data: Data(key.utf8))) + let hex = mac.map { String(format: "%02X", $0) }.joined() + let body = hex.prefix(2) + lowHex + hex.dropFirst(2).prefix(56) + highHex + hex.suffix(6) + return Data(hexString: "2221B3002200" + body + "00FF") + } +} + +nonisolated extension Data { + init?(hexString: String) { + let characters = Array(hexString) + guard characters.count.isMultiple(of: 2) else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(characters.count / 2) + for index in stride(from: 0, to: characters.count, by: 2) { + guard let byte = UInt8(String(characters[index ... index + 1]), radix: 16) else { return nil } + bytes.append(byte) + } + self.init(bytes) + } + + var hexString: String { + map { String(format: "%02X", $0) }.joined() + } +} diff --git a/Inkless/Printer/PrintError.swift b/Inkless/Printer/PrintError.swift new file mode 100644 index 0000000..9a47ef8 --- /dev/null +++ b/Inkless/Printer/PrintError.swift @@ -0,0 +1,48 @@ +import Foundation + +enum PrintError: LocalizedError, Equatable { + case notConnected, noPaper, overheated, lowBattery, rejected(UInt8), timedOut, cancelled, linkLost + + init(_ fault: MXW.Status.Fault) { + self = switch fault { + case .noPaper: .noPaper + case .overheated: .overheated + case .lowBattery: .lowBattery + case let .unknown(code): .rejected(code) + } + } + + var errorDescription: String? { + switch self { + case .notConnected: "Printer Not Connected" + case .noPaper: "Out of Paper" + case .overheated: "Print Head Overheated" + case .lowBattery: "Printer Battery Low" + case let .rejected(code): "Printer Rejected the Job (\(code))" + case .timedOut: "Printer Not Responding" + case .cancelled: "Printing Cancelled" + case .linkLost: "Connection Lost" + } + } + + var recoverySuggestion: String? { + switch self { + case .notConnected, .linkLost, .timedOut: "Turn the printer on and bring it closer to your iPhone." + case .noPaper: "Load a paper roll and close the cover." + case .overheated: "Let the printer cool down for a couple of minutes." + case .lowBattery: "Connect the printer to a charger." + case .rejected, .cancelled: nil + } + } +} + +extension MXW.Status.Fault { + var title: String { + switch self { + case .noPaper: "Out of Paper" + case .overheated: "Overheated" + case .lowBattery: "Low Battery" + case let .unknown(code): "Error \(code)" + } + } +} diff --git a/Inkless/Printer/PrinterManager+Bluetooth.swift b/Inkless/Printer/PrinterManager+Bluetooth.swift new file mode 100644 index 0000000..cc4e5b9 --- /dev/null +++ b/Inkless/Printer/PrinterManager+Bluetooth.swift @@ -0,0 +1,103 @@ +import CoreBluetooth +import SwiftUI + +extension PrinterManager: CBCentralManagerDelegate, CBPeripheralDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + if link == .bluetoothOff || link == .unauthorized { + link = .idle + } + reconnectIfRemembered() + startScanning() + case .unauthorized: + resetLink() + link = .unauthorized + case .poweredOff, .unsupported, .resetting: + resetLink() + link = .bluetoothOff + default: + break + } + } + + func centralManager( + _: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi: NSNumber + ) { + let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name ?? "" + guard name.uppercased().hasPrefix("MXW") || name.uppercased() == "C17" else { return } + found[peripheral.identifier] = peripheral + lastSeen[peripheral.identifier] = .now + if peripheral.identifier == awaitedPeripheralID, link != .ready { + central?.connect(peripheral) + } + if let index = discovered.firstIndex(where: { $0.id == peripheral.identifier }) { + if abs(discovered[index].rssi - rssi.intValue) >= 8 { + discovered[index].rssi = rssi.intValue + } + } else { + withAnimation(.snappy) { + discovered.append(Discovered(id: peripheral.identifier, name: name, rssi: rssi.intValue)) + } + } + } + + func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) { + remember(id: peripheral.identifier, name: printerName) + peripheral.discoverServices([MXW.service]) + } + + func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error _: Error?) { + resetLink() + link = .idle + reconnectIfRemembered() + startScanning() + } + + func centralManager(_: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?) { + resetLink() + withAnimation(.smooth) { link = .idle } + reconnectIfRemembered() + startScanning() + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) { + guard let service = peripheral.services?.first(where: { $0.uuid == MXW.service }) else { + central?.cancelPeripheralConnection(peripheral) + return + } + let wanted = [MXW.commandCharacteristic, MXW.notifyCharacteristic, MXW.dataCharacteristic] + peripheral.discoverCharacteristics(wanted, for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error _: Error?) { + for characteristic in service.characteristics ?? [] { + switch characteristic.uuid { + case MXW.commandCharacteristic: commandCharacteristic = characteristic + case MXW.dataCharacteristic: dataCharacteristic = characteristic + case MXW.notifyCharacteristic: peripheral.setNotifyValue(true, for: characteristic) + default: break + } + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error _: Error?) { + guard characteristic.isNotifying, commandCharacteristic != nil, dataCharacteristic != nil else { return } + stopScanning() + connectedThisSession.insert(peripheral.identifier) + withAnimation(.smooth) { link = .ready } + send(MXW.versionRequest()) + startStatusPolling() + } + + func peripheral(_: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error _: Error?) { + guard let value = characteristic.value, let notification = MXW.parse(value) else { return } + handle(notification) + } + + func peripheralIsReady(toSendWriteWithoutResponse _: CBPeripheral) { + writeReady?.resume() + writeReady = nil + } +} diff --git a/Inkless/Printer/PrinterManager+Discovery.swift b/Inkless/Printer/PrinterManager+Discovery.swift new file mode 100644 index 0000000..10ebd3e --- /dev/null +++ b/Inkless/Printer/PrinterManager+Discovery.swift @@ -0,0 +1,70 @@ +import CoreBluetooth +import SwiftUI + +private let presenceTimeout = 4.0 +private let connectTimeout = 8.0 + +extension PrinterManager { + func startScanning() { + if isDemo { + guard discovered.isEmpty else { return } + link = .scanning + Task { + try? await Task.sleep(for: .seconds(1.2)) + guard link == .scanning else { return } + withAnimation(.snappy) { discovered = [Discovered(id: UUID(), name: "MXW01", rssi: -48)] } + } + return + } + activate() + guard let central, central.state == .poweredOn, link != .ready else { return } + if !hasRememberedPrinter { + link = .scanning + } + central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + watchPresence() + } + + func rescan() { + lastSeen = [:] + found = [:] + discovered = [] + startScanning() + } + + func abandonIfStalled(_ target: CBPeripheral) { + Task { + try? await Task.sleep(for: .seconds(connectTimeout)) + guard link == .connecting, !hasRememberedPrinter, awaitedPeripheralID == target.identifier else { return } + central?.cancelPeripheralConnection(target) + resetLink() + printerName = nil + link = .idle + rescan() + } + } + + func stopScanning() { + central?.stopScan() + presenceTask?.cancel() + lastSeen = [:] + withAnimation(.snappy) { discovered = [] } + } + + func watchPresence() { + presenceTask?.cancel() + presenceTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + let cutoff = Date.now.addingTimeInterval(-presenceTimeout) + let gone = Set(lastSeen.filter { $0.value < cutoff }.keys) + guard !gone.isEmpty else { continue } + for id in gone { + lastSeen[id] = nil + found[id] = nil + } + withAnimation(.snappy) { discovered.removeAll { gone.contains($0.id) } } + } + } + } +} diff --git a/Inkless/Printer/PrinterManager.swift b/Inkless/Printer/PrinterManager.swift new file mode 100644 index 0000000..7206b48 --- /dev/null +++ b/Inkless/Printer/PrinterManager.swift @@ -0,0 +1,430 @@ +import CoreBluetooth +import Observation +import SwiftUI + +@Observable +final class PrinterManager: NSObject { + enum Link: Equatable { + case bluetoothOff, unauthorized, idle, scanning, connecting, ready + } + + struct Discovered: Identifiable, Equatable { + let id: UUID + let name: String + var rssi: Int + } + + struct Job: Equatable { + enum Phase: Equatable { case preparing, sending, finishing } + + var phase: Phase + var rows: Int + var progress: Double + } + + var link: Link = .idle + var discovered: [Discovered] = [] + var printerName: String? + private(set) var status: MXW.Status? + private(set) var firmware: String? + private(set) var job: Job? + private(set) var hasRememberedPrinter = UserDefaults.standard.string(forKey: Keys.peripheral) != nil + + var density: Double = UserDefaults.standard.object(forKey: Keys.density) as? Double ?? 0.75 { + didSet { UserDefaults.standard.set(density, forKey: Keys.density) } + } + + var connectedThisSession: Set = [] + + var awaitedPeripheralID: UUID? { + peripheral?.identifier + } + + var isPrinting: Bool { + job != nil + } + + private enum Keys { + static let peripheral = "printer.peripheral" + static let name = "printer.name" + static let density = "printer.density" + static let dotSpeed = "printer.speed.dots" + static let graySpeed = "printer.speed.gray" + } + + private enum Pacing { + static let chunkLimit = 180 + static let chunkInterval = Duration.milliseconds(15) + static let flowHoldLimit = Duration.seconds(4) + static let assumedDotRowsPerSecond = 50.0 + static let assumedGrayRowsPerSecond = 12.0 + static let progressCeiling = 0.98 + } + + @ObservationIgnored var central: CBCentralManager? + @ObservationIgnored private var peripheral: CBPeripheral? + @ObservationIgnored var found: [UUID: CBPeripheral] = [:] + @ObservationIgnored var commandCharacteristic: CBCharacteristic? + @ObservationIgnored var dataCharacteristic: CBCharacteristic? + @ObservationIgnored private var waiters: [Waiter] = [] + @ObservationIgnored var writeReady: CheckedContinuation? + @ObservationIgnored private var flowHeld = false + @ObservationIgnored var lastSeen: [UUID: Date] = [:] + @ObservationIgnored var presenceTask: Task? + @ObservationIgnored private var sentFraction = 0.0 + @ObservationIgnored private var flowResumed: CheckedContinuation? + @ObservationIgnored private var headType = MXW.HeadType.unknown + @ObservationIgnored private var printTask: Task? + @ObservationIgnored private var statusPoll: Task? + + private struct Waiter { + let id: UUID + let match: (MXW.Notification) -> Bool + let continuation: CheckedContinuation + } + + let isDemo: Bool + + override init() { + #if targetEnvironment(simulator) + isDemo = true + #else + isDemo = ProcessInfo.processInfo.arguments.contains("-demo") + #endif + super.init() + printerName = UserDefaults.standard.string(forKey: Keys.name) + } + + func activate() { + if isDemo { + if link == .idle { + if hasRememberedPrinter { + demoConnect() + } else { + startScanning() + } + } + return + } + guard central == nil else { return } + central = CBCentralManager(delegate: self, queue: .main) + } + + func connect(_ device: Discovered) { + if isDemo { + remember(id: device.id, name: device.name) + connectedThisSession.insert(device.id) + printerName = device.name + demoConnect() + return + } + guard let target = found[device.id] else { return } + connect(target, name: device.name) + abandonIfStalled(target) + } + + func forgetPrinter() { + UserDefaults.standard.removeObject(forKey: Keys.peripheral) + UserDefaults.standard.removeObject(forKey: Keys.name) + if let peripheral { + central?.cancelPeripheralConnection(peripheral) + } + resetLink() + hasRememberedPrinter = false + printerName = nil + firmware = nil + link = .idle + startScanning() + } + + func refreshStatus() { + guard !isDemo else { return } + send(MXW.statusRequest()) + } + + func feedPaper() { + print(.dots(.blank(rows: MXW.minimumRows))) + } + + func print(_ raster: PrintRaster, onFinish: ((Result) -> Void)? = nil) { + guard printTask == nil else { return } + printTask = Task { + defer { printTask = nil } + do { + try await run(raster.padded(toMinimumHeight: MXW.minimumRows)) + withAnimation(.smooth) { job = nil } + onFinish?(.success(())) + } catch { + withAnimation(.smooth) { job = nil } + let failure = (error as? PrintError) ?? (error is CancellationError ? .cancelled : .timedOut) + if failure == .cancelled { + send(MXW.cancel()) + } + onFinish?(.failure(failure)) + } + } + } + + func cancelPrint() { + printTask?.cancel() + } + + func remember(id: UUID, name: String?) { + UserDefaults.standard.set(id.uuidString, forKey: Keys.peripheral) + UserDefaults.standard.set(name, forKey: Keys.name) + hasRememberedPrinter = true + } + + private func connect(_ target: CBPeripheral, name: String) { + peripheral = target + target.delegate = self + printerName = name + link = .connecting + central?.connect(target) + } + + func reconnectIfRemembered() { + guard let central, peripheral == nil, + let stored = UserDefaults.standard.string(forKey: Keys.peripheral), + let id = UUID(uuidString: stored), + let known = central.retrievePeripherals(withIdentifiers: [id]).first else { return } + connect(known, name: printerName ?? known.name ?? "MXW01") + } + + func resetLink() { + statusPoll?.cancel() + commandCharacteristic = nil + dataCharacteristic = nil + peripheral = nil + status = nil + for waiter in waiters { + waiter.continuation.resume(throwing: PrintError.linkLost) + } + waiters = [] + writeReady?.resume() + writeReady = nil + releaseFlow() + } + + private func run(_ raster: PrintRaster) async throws { + guard link == .ready else { throw PrintError.notConnected } + job = Job(phase: .preparing, rows: raster.height, progress: 0) + let paperTime = max(1.5, Double(raster.height) / measuredSpeed(gray: raster.isGray)) + if isDemo { + return try await demoPrint(duration: paperTime) + } + guard let peripheral, let dataCharacteristic else { throw PrintError.notConnected } + + let mode: MXW.PrintMode = raster.isGray ? .gray : .dots + send(MXW.density(MXW.compensatedDensity( + requested: Int((70 + density * 30).rounded()), temperature: status?.temperature ?? 0, + blackRatio: raster.blackRatio, head: headType, mode: mode + ))) + let current = try await request(MXW.statusRequest(), timeout: 3) { $0.isStatus } + if case let .status(status) = current, let fault = status.fault { + throw PrintError(fault) + } + + let reply = try await request(MXW.startPrint(rows: raster.height, mode: mode), timeout: 5) { $0.isPrintReply } + if case let .printRejected(code) = reply { + throw PrintError.rejected(code) + } + + job?.phase = .sending + sentFraction = 0 + let started = Date.now + let ticker = Task { + while !Task.isCancelled { + let timed = Date.now.timeIntervalSince(started) / paperTime + job?.progress = min(timed, sentFraction, Pacing.progressCeiling) + try? await Task.sleep(for: .milliseconds(80)) + } + } + defer { ticker.cancel() } + try await stream( + raster.payload(fourBitGray: MXW.usesFourBitGray(firmware: firmware)), + to: dataCharacteristic, + on: peripheral + ) + + job?.phase = .finishing + let completion = Task { try await self.wait(timeout: 30 + paperTime * 2) { $0 == .printComplete } } + send(MXW.flush()) + _ = try await withTaskCancellationHandler { try await completion.value } onCancel: { completion.cancel() } + recordSpeed(rows: raster.height, seconds: Date.now.timeIntervalSince(started), gray: raster.isGray) + job?.progress = 1 + } + + private func measuredSpeed(gray: Bool) -> Double { + let stored = UserDefaults.standard.double(forKey: gray ? Keys.graySpeed : Keys.dotSpeed) + return stored > 0 ? stored : gray ? Pacing.assumedGrayRowsPerSecond : Pacing.assumedDotRowsPerSecond + } + + private func recordSpeed(rows: Int, seconds: Double, gray: Bool) { + guard seconds > 1 else { return } + let key = gray ? Keys.graySpeed : Keys.dotSpeed + let stored = UserDefaults.standard.double(forKey: key) + let measured = Double(rows) / seconds + UserDefaults.standard.set(stored > 0 ? (stored + measured) / 2 : measured, forKey: key) + } + + private func stream(_ payload: Data, to characteristic: CBCharacteristic, on peripheral: CBPeripheral) async throws { + let chunkSize = max(20, min(peripheral.maximumWriteValueLength(for: .withoutResponse), Pacing.chunkLimit)) + var offset = 0 + while offset < payload.count { + try Task.checkCancellation() + guard link == .ready else { throw PrintError.linkLost } + await waitUntilWritable(peripheral) + let end = min(offset + chunkSize, payload.count) + peripheral.writeValue(payload.subdata(in: offset ..< end), for: characteristic, type: .withoutResponse) + offset = end + try await Task.sleep(for: Pacing.chunkInterval) + sentFraction = Double(offset) / Double(payload.count) + } + } + + func send(_ data: Data) { + guard let peripheral, let commandCharacteristic else { return } + peripheral.writeValue(data, for: commandCharacteristic, type: .withoutResponse) + } + + private func request( + _ data: Data, timeout: Double, expecting match: @escaping (MXW.Notification) -> Bool + ) async throws -> MXW.Notification { + let pending = Task { try await self.wait(timeout: timeout, for: match) } + await Task.yield() + send(data) + return try await withTaskCancellationHandler { try await pending.value } onCancel: { pending.cancel() } + } + + private func wait( + timeout: Double, for match: @escaping (MXW.Notification) -> Bool + ) async throws -> MXW.Notification { + let waiterID = UUID() + let timer = Task { + try await Task.sleep(for: .seconds(timeout)) + self.failWaiter(waiterID, with: PrintError.timedOut) + } + defer { timer.cancel() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiters.append(Waiter(id: waiterID, match: match, continuation: continuation)) + } + } onCancel: { + Task { @MainActor in self.failWaiter(waiterID, with: CancellationError()) } + } + } + + private func failWaiter(_ id: UUID, with error: Error) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + waiters.remove(at: index).continuation.resume(throwing: error) + } + + private func waitUntilWritable(_ peripheral: CBPeripheral) async { + if flowHeld { + let release = Task { + try await Task.sleep(for: Pacing.flowHoldLimit) + self.releaseFlow() + } + await withCheckedContinuation { flowResumed = $0 } + release.cancel() + } + if !peripheral.canSendWriteWithoutResponse { + await withCheckedContinuation { writeReady = $0 } + } + } + + private func releaseFlow() { + flowHeld = false + flowResumed?.resume() + flowResumed = nil + } +} + +extension PrinterManager { + func handle(_ notification: MXW.Notification) { + switch notification { + case let .status(new): + withAnimation(.smooth) { status = new } + case let .version(text, head): + firmware = text + headType = head + case let .challenge(challenge): + if let reply = MXW.challengeResponse(to: challenge) { + send(reply) + } + case .flowPause: + flowHeld = true + case .flowResume: + releaseFlow() + default: + break + } + for waiter in waiters where waiter.match(notification) { + guard let index = waiters.firstIndex(where: { $0.id == waiter.id }) else { continue } + waiters.remove(at: index).continuation.resume(returning: notification) + } + } + + func startStatusPolling() { + statusPoll?.cancel() + statusPoll = Task { + while !Task.isCancelled { + if !isPrinting { + send(MXW.statusRequest()) + } + try? await Task.sleep(for: .seconds(15)) + } + } + } + + private func demoConnect() { + link = .connecting + Task { + try? await Task.sleep(for: .seconds(0.9)) + withAnimation(.smooth) { + link = .ready + status = MXW.Status(activity: .idle, battery: 87, temperature: 31, fault: nil) + firmware = "MXW 2.1 D2" + } + } + } + + private func demoPrint(duration: Double) async throws { + try await Task.sleep(for: .seconds(0.4)) + let start = Date.now + while true { + try Task.checkCancellation() + let elapsed = Date.now.timeIntervalSince(start) / duration + if elapsed >= 1 { + break + } + job?.progress = elapsed + job?.phase = elapsed > 0.5 ? .finishing : .sending + try await Task.sleep(for: .milliseconds(50)) + } + job?.progress = 1 + } +} + +private extension PrintRaster { + var isGray: Bool { + if case .gray = self { + true + } else { + false + } + } + + func payload(fourBitGray: Bool) -> Data { + switch self { + case let .dots(bitmap): + return bitmap.packedRows() + case let .gray(bitmap): + if fourBitGray { + return bitmap.packedNibbles() + } + return bitmap.packedBytes() + } + } +} diff --git a/Inkless/UI/ImageSources.swift b/Inkless/UI/ImageSources.swift new file mode 100644 index 0000000..a9d40e1 --- /dev/null +++ b/Inkless/UI/ImageSources.swift @@ -0,0 +1,56 @@ +import ImageIO +import SwiftUI +import UIKit + +nonisolated enum ImageDecoding { + static func cgImage(from data: Data, maxPixelSize: Int = 2048) -> CGImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] + return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + } +} + +struct CameraPicker: UIViewControllerRepresentable { + var onCapture: (CGImage) -> Void + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_: UIImagePickerController, context _: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private let parent: CameraPicker + init(_ parent: CameraPicker) { + self.parent = parent + } + + func imagePickerController( + _: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + if let image = info[.originalImage] as? UIImage, + let data = image.jpegData(compressionQuality: 0.95), + let decoded = ImageDecoding.cgImage(from: data) { + parent.onCapture(decoded) + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_: UIImagePickerController) { + parent.dismiss() + } + } +} diff --git a/Inkless/UI/PairingSheet.swift b/Inkless/UI/PairingSheet.swift new file mode 100644 index 0000000..a476f2b --- /dev/null +++ b/Inkless/UI/PairingSheet.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct PairingSheet: View { + @Environment(PrinterManager.self) private var printer + @Environment(\.dismiss) private var dismiss + @State private var contentHeight: CGFloat = 460 + + var body: some View { + VStack(spacing: 0) { + HStack { + Spacer() + Button("Close", systemImage: "xmark") { dismiss() } + .labelStyle(.iconOnly) + .buttonStyle(.glass) + .buttonBorderShape(.circle) + } + + Text(title) + .font(.title.weight(.bold)) + .multilineTextAlignment(.center) + .contentTransition(.opacity) + Text(subtitle) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2, reservesSpace: true) + .padding(.top, 6) + .contentTransition(.opacity) + + PrinterHero(dimmed: searching) + .frame(height: 200) + .background { + if searching { + SearchPulse() + } + } + .scaleEffect(searching ? 0.9 : 1) + .padding(.vertical, 12) + .animation(.bouncy, value: searching) + + action.frame(height: 52) + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { contentHeight = $0 } + .presentationDetents([.height(contentHeight)]) + .presentationDragIndicator(.hidden) + .animation(.smooth, value: printer.link) + .animation(.smooth, value: printer.discovered) + .sensoryFeedback(.impact(weight: .medium), trigger: printer.discovered.count) { _, new in new > 0 } + .sensoryFeedback(.success, trigger: printer.link) { _, new in new == .ready } + .onAppear { + if printer.link != .ready { + printer.rescan() + } + } + .onChange(of: printer.link) { _, link in + guard link == .ready else { return } + Task { + try? await Task.sleep(for: .seconds(1.4)) + dismiss() + } + } + } + + private var searching: Bool { + printer.link == .scanning && printer.discovered.isEmpty + } + + @ViewBuilder + private var action: some View { + switch printer.link { + case .ready: + Label("Connected", systemImage: "checkmark.circle.fill") + .font(.headline) + .foregroundStyle(.tint) + .symbolEffect(.bounce, value: printer.link) + .transition(.blurReplace) + case .connecting: + ProgressView() + .controlSize(.large) + .transition(.blurReplace) + case .bluetoothOff, .unauthorized: + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.glass) + .controlSize(.large) + default: + if let device = printer.discovered.max(by: { $0.rssi < $1.rssi }) { + Button { + printer.connect(device) + } label: { + Text("Connect").frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .controlSize(.large) + .transition(.blurReplace) + } + } + } + + private var title: String { + switch printer.link { + case .bluetoothOff: "Bluetooth Is Off" + case .unauthorized: "Bluetooth Access Needed" + case .ready, .connecting: printer.printerName ?? "MXW01" + default: printer.discovered.first?.name ?? "Looking for Printer" + } + } + + private var subtitle: String { + switch printer.link { + case .ready: "Connected and ready to print." + case .bluetoothOff: "Turn Bluetooth on in Control Centre." + case .unauthorized: "Allow Inkless to use Bluetooth in Settings." + case .connecting: "Connecting…" + default: printer.discovered.isEmpty + ? "Hold the power button until the light comes on." + : "Thermal printer nearby." + } + } +} + +private struct SearchPulse: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + TimelineView(.animation(paused: reduceMotion)) { timeline in + let time = timeline.date.timeIntervalSinceReferenceDate + ZStack { + ForEach(0 ..< 3) { ring in + let phase = (time / 2.4 + Double(ring) / 3).truncatingRemainder(dividingBy: 1) + Circle() + .stroke(.tint.opacity(0.35 * (1 - phase)), lineWidth: 1.5) + .scaleEffect(0.45 + phase * 0.55) + } + } + } + .frame(width: 240, height: 240) + } +} diff --git a/Inkless/UI/PaperView.swift b/Inkless/UI/PaperView.swift new file mode 100644 index 0000000..50d2ff3 --- /dev/null +++ b/Inkless/UI/PaperView.swift @@ -0,0 +1,200 @@ +import SwiftUI + +struct PaperView: View { + static let marginDots = 40 + static let paperDots = PrintHead.dots + marginDots * 2 + static let verticalMarginRatio = 0.6 + + let image: CGImage + let pixelsPerDot: Int + + @Environment(\.displayScale) private var displayScale + @Environment(\.colorScheme) private var colorScheme + + static func fittingPixelsPerDot(in available: CGFloat, displayScale: CGFloat) -> Int { + max(1, Int(available * displayScale) / paperDots) + } + + var body: some View { + let dot = CGFloat(pixelsPerDot) / displayScale + let margin = CGFloat(Self.marginDots) * dot + + Image(decorative: image, scale: 1) + .interpolation(.none) + .resizable() + .frame(width: CGFloat(image.width) * dot, height: CGFloat(image.height) * dot) + .padding(.horizontal, margin) + .padding(.vertical, margin * Self.verticalMarginRatio) + .background(.white, in: TornEdge(tooth: 3)) + .compositingGroup() + .brightness(colorScheme == .dark ? -0.08 : 0) + .shadow(color: .black.opacity(0.10), radius: 1, y: 1) + .shadow(color: .black.opacity(0.14), radius: 14, y: 8) + .accessibilityLabel("Print preview") + } +} + +nonisolated struct TornEdge: Shape { + var tooth: CGFloat + + func path(in rect: CGRect) -> Path { + let count = max(1, Int(rect.width / (tooth * 2))) + let step = rect.width / CGFloat(count) + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY + tooth)) + for index in 0 ..< count { + let x = rect.minX + CGFloat(index) * step + path.addLine(to: CGPoint(x: x + step / 2, y: rect.minY)) + path.addLine(to: CGPoint(x: x + step, y: rect.minY + tooth)) + } + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - tooth)) + for index in (0 ..< count).reversed() { + let x = rect.minX + CGFloat(index) * step + path.addLine(to: CGPoint(x: x + step / 2, y: rect.maxY)) + path.addLine(to: CGPoint(x: x, y: rect.maxY - tooth)) + } + path.closeSubpath() + return path + } +} + +struct PaperStage: View { + let image: CGImage + var original: CGImage? + var rotation = 0 + + @Environment(PrinterManager.self) private var printer + @Environment(AppModel.self) private var model + @Environment(\.displayScale) private var displayScale + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var zoomed = false + @State private var comparing = false + + var body: some View { + GeometryReader { geometry in + let fit = PaperView.fittingPixelsPerDot(in: geometry.size.width - 64, displayScale: displayScale) + let factor = zoomed ? fit * 2 : fit + let paperHeight = CGFloat(image.height * factor) / displayScale + 60 + + ScrollView(zoomed ? [.horizontal, .vertical] : .vertical) { + paper(factor: factor) + .padding(.vertical, 24) + .padding(.horizontal, zoomed ? 24 : 0) + .frame(minWidth: geometry.size.width, minHeight: geometry.size.height) + .offset(y: feedOffset(paperHeight: paperHeight, stage: geometry.size.height)) + .id(model.completedJobs) + .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)) + } + .scrollBounceBehavior(.basedOnSize) + .scrollDisabled(printer.isPrinting) + .animation(.smooth(duration: 0.5), value: model.completedJobs) + } + .mask(alignment: .top) { Rectangle().ignoresSafeArea(edges: printer.isPrinting ? [] : .top) } + .overlay(alignment: .top) { + if printer.isPrinting { + PrinterSlot().transition(.opacity) + } + } + .animation(.smooth, value: printer.isPrinting) + .sensoryFeedback(.impact(weight: .light), trigger: zoomed) + .sensoryFeedback(.selection, trigger: comparing) + } + + private func paper(factor: Int) -> some View { + PaperView(image: image, pixelsPerDot: factor) + .overlay { + if comparing, let original { + originalOverlay(original) + } + } + .overlay(alignment: .topLeading) { + if !zoomed, !printer.isPrinting { + PaperRuler(rows: image.height, pixelsPerDot: factor).offset(x: -34) + } + } + .onTapGesture(count: 2) { withAnimation(.snappy) { zoomed.toggle() } } + .onLongPressGesture(minimumDuration: 0.25, perform: {}, onPressingChanged: { pressing in + guard original != nil else { return } + withAnimation(.easeOut(duration: 0.15)) { comparing = pressing } + }) + .simultaneousGesture(MagnifyGesture().onEnded { value in + withAnimation(.snappy) { + if value.magnification > 1.15 { + zoomed = true + } + if value.magnification < 0.85 { + zoomed = false + } + } + }) + } + + private func feedOffset(paperHeight: CGFloat, stage: CGFloat) -> CGFloat { + guard let job = printer.job, !reduceMotion else { return 0 } + return -CGFloat(job.progress) * (max(paperHeight, stage) + 40) + } + + private func originalOverlay(_ original: CGImage) -> some View { + GeometryReader { geometry in + let margin = geometry.size.width * CGFloat(PaperView.marginDots) / CGFloat(PaperView.paperDots) + Image(decorative: original, scale: 1) + .resizable() + .rotationEffect(.degrees(Double(rotation) * 90)) + .scaledToFit() + .frame(width: geometry.size.width - margin * 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + Text("Original") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .glassEffect() + .padding(.top, 24) + } + } + .transition(.opacity) + } +} + +private struct PrinterSlot: View { + var body: some View { + Capsule() + .fill(.black.opacity(0.85)) + .frame(height: 6) + .padding(.horizontal, 28) + .shadow(color: .black.opacity(0.3), radius: 6, y: 3) + .accessibilityHidden(true) + } +} + +struct PaperRuler: View { + let rows: Int + let pixelsPerDot: Int + + @Environment(\.displayScale) private var displayScale + + var body: some View { + let millimetre = CGFloat(pixelsPerDot) * PrintHead.dotsPerMillimetre / displayScale + let top = CGFloat(PaperView.marginDots) * PaperView.verticalMarginRatio * CGFloat(pixelsPerDot) / displayScale + let length = Int(Double(rows) / PrintHead.dotsPerMillimetre) + + Canvas { context, _ in + for mark in stride(from: 0, through: length, by: 5) { + let y = top + CGFloat(mark) * millimetre + let major = mark.isMultiple(of: 10) + var tick = Path() + tick.move(to: CGPoint(x: major ? 20 : 24, y: y)) + tick.addLine(to: CGPoint(x: 30, y: y)) + context.stroke(tick, with: .color(.secondary.opacity(major ? 0.9 : 0.5)), lineWidth: 1) + if major { + let label = Text("\(mark)").font(.caption2.monospacedDigit()).foregroundStyle(.secondary) + context.draw(label, at: CGPoint(x: 16, y: y), anchor: .trailing) + } + } + } + .frame(width: 30, height: top + CGFloat(length) * millimetre + 8) + .dynamicTypeSize(.large) + .accessibilityHidden(true) + } +} diff --git a/Inkless/UI/PhotoControls.swift b/Inkless/UI/PhotoControls.swift new file mode 100644 index 0000000..c8a7e88 --- /dev/null +++ b/Inkless/UI/PhotoControls.swift @@ -0,0 +1,256 @@ +import SwiftUI + +struct PhotoControls: View { + let pro: Bool + + @State private var panel = Panel.launchDefault + + enum Panel: Hashable { + case style, tone + + static var launchDefault: Panel { + UserDefaults.standard.string(forKey: "panel") == "tone" ? .tone : .style + } + } + + var body: some View { + VStack(spacing: 12) { + if pro { + Group { + switch panel { + case .style: StylePanel() + case .tone: TonePanel() + } + } + .frame(height: 150) + .transition(.blurReplace) + + Picker("Panel", selection: $panel.animation(.smooth(duration: 0.3))) { + Text("Style").tag(Panel.style) + Text("Tone").tag(Panel.tone) + } + .pickerStyle(.segmented) + .frame(maxWidth: 220) + } else { + SimplePanel().transition(.blurReplace) + } + } + .padding(.top, 10) + .frame(maxWidth: .infinity) + } +} + +private struct SimplePanel: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var photo = model.photo + + VStack(spacing: 14) { + HStack(spacing: 10) { + ForEach(Look.allCases) { look in + ProofTile(title: look.title, image: photo.proofs[look.style], selected: photo.look == look) { + photo.look = look + } + } + } + + Slider(value: $photo.settings.tone.brightness, in: -0.6 ... 0.6) { + Text("Brightness") + } minimumValueLabel: { + Image(systemName: "sun.min").foregroundStyle(.secondary) + } maximumValueLabel: { + Image(systemName: "sun.max.fill").foregroundStyle(.secondary) + } + .padding(.horizontal, 28) + } + .sensoryFeedback(.selection, trigger: photo.look) + } +} + +private struct StylePanel: View { + @Environment(AppModel.self) private var model + + var body: some View { + let photo = model.photo + + VStack(spacing: 8) { + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 10) { + ForEach(PrintStyle.all) { style in + ProofTile(title: style.title, image: photo.proofs[style], selected: photo.settings.style == style) { + photo.settings.style = style + withAnimation(.snappy) { proxy.scrollTo(style, anchor: .center) } + } + .id(style) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 4) + } + .scrollIndicators(.hidden) + .onAppear { proxy.scrollTo(photo.settings.style, anchor: .center) } + } + + Text(photo.settings.style.summary) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .contentTransition(.opacity) + .animation(.smooth(duration: 0.2), value: photo.settings.style) + } + .sensoryFeedback(.selection, trigger: photo.settings.style) + } +} + +private struct ProofTile: View { + static let side: CGFloat = 72 + + let title: String + let image: CGImage? + let selected: Bool + let action: () -> Void + + @Environment(\.displayScale) private var displayScale + + var body: some View { + Button(action: action) { + VStack(spacing: 5) { + Color.white + .overlay { + if let image { + Image(decorative: image, scale: displayScale).interpolation(.none) + } + } + .frame(width: Self.side, height: Self.side) + .clipShape(.rect(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .strokeBorder(.tint, lineWidth: selected ? 2.5 : 0) + .padding(-4) + } + Text(title) + .font(.caption2.weight(selected ? .semibold : .regular)) + .foregroundStyle(selected ? .primary : .secondary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(width: Self.side + 8) + } + .padding(.top, 4) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +private struct TonePanel: View { + @Environment(AppModel.self) private var model + @State private var parameter = Parameter.brightness + + enum Parameter: String, CaseIterable, Identifiable { + case brightness, contrast, gamma, sharpness + + var id: String { + rawValue + } + + var title: String { + switch self { + case .brightness: "Brightness" + case .contrast: "Contrast" + case .gamma: "Midtones" + case .sharpness: "Sharpness" + } + } + + var symbol: String { + switch self { + case .brightness: "sun.max.fill" + case .contrast: "circle.lefthalf.filled" + case .gamma: "circle.dotted.circle" + case .sharpness: "triangle" + } + } + + var range: ClosedRange { + self == .sharpness ? 0 ... 1 : -1 ... 1 + } + + var keyPath: WritableKeyPath { + switch self { + case .brightness: \.brightness + case .contrast: \.contrast + case .gamma: \.gamma + case .sharpness: \.sharpness + } + } + } + + var body: some View { + @Bindable var photo = model.photo + let value = $photo.settings.tone[dynamicMember: parameter.keyPath] + + VStack(spacing: 6) { + HStack { + Text(parameter.title).font(.footnote.weight(.semibold)).foregroundStyle(.secondary) + if !photo.settings.tone.isNeutral { + Button("Reset") { withAnimation(.snappy) { photo.resetTone() } } + .font(.footnote) + .transition(.opacity) + } + } + .frame(minHeight: 22) + + HStack(spacing: 18) { + ForEach(Parameter.allCases) { item in + Dial(parameter: item, value: photo.settings.tone[keyPath: item.keyPath], selected: item == parameter) { + withAnimation(.snappy) { parameter = item } + } + } + } + + RulerSlider(value: value, range: parameter.range, steps: parameter == .sharpness ? 20 : 40) + .id(parameter) + } + .sensoryFeedback(.selection, trigger: parameter) + } + + private struct Dial: View { + let parameter: Parameter + let value: Double + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + Circle().fill(selected ? AnyShapeStyle(.tint.opacity(0.16)) : AnyShapeStyle(.clear)) + Circle().stroke(.quaternary, lineWidth: 2) + Circle() + .trim(from: 0, to: abs(value)) + .stroke(.tint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .scaleEffect(x: value < 0 ? -1 : 1) + if selected, value != 0 { + Text(Int((value * 100).rounded()), format: .number.sign(strategy: .always(includingZero: false))) + .font(.footnote.weight(.semibold).monospacedDigit()) + .contentTransition(.numericText(value: value)) + } else { + Image(systemName: parameter.symbol) + .foregroundStyle(selected ? .primary : .secondary) + } + } + .frame(width: 48, height: 48) + .contentShape(.circle) + } + .buttonStyle(.plain) + .accessibilityLabel(parameter.title) + .accessibilityValue(Text(Int((value * 100).rounded()), format: .number)) + .accessibilityAddTraits(selected ? .isSelected : []) + } + } +} diff --git a/Inkless/UI/PhotoScreen.swift b/Inkless/UI/PhotoScreen.swift new file mode 100644 index 0000000..e4c6800 --- /dev/null +++ b/Inkless/UI/PhotoScreen.swift @@ -0,0 +1,152 @@ +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +struct PhotoScreen: View { + @Environment(AppModel.self) private var model + @AppStorage("photo.proControls") private var proControls = false + + @State private var pickedItem: PhotosPickerItem? + @State private var showingPhotos = false + @State private var showingCamera = false + @State private var showingFiles = false + @State private var importing = false + @State private var importFailed = false + + var body: some View { + let photo = model.photo + + NavigationStack { + ZStack { + if importing { + ProgressView("Loading Photo…") + } else if let preview = photo.preview { + PaperStage(image: preview, original: photo.source, rotation: photo.settings.rotation) + .toolbar { editingToolbar } + .transition(.opacity) + } else if photo.source != nil { + ProgressView() + } else { + emptyState.transition(.opacity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaBar(edge: .bottom, spacing: 0) { + VStack(spacing: 10) { + if photo.preview != nil { + PhotoControls(pro: proControls) + } + PrinterBar() + } + } + .background(Color(.systemGroupedBackground)) + .navigationTitle(photo.source == nil ? "Photo" : "") + .toolbarTitleDisplayMode(photo.source == nil ? .large : .inline) + .animation(.smooth, value: photo.source == nil) + } + .photosPicker(isPresented: $showingPhotos, selection: $pickedItem, matching: .images, preferredItemEncoding: .current) + .alert("Couldn’t Load Photo", isPresented: $importFailed) { + Button("OK", role: .cancel) {} + } message: { + Text("The photo may still be downloading from iCloud. Try again in a moment.") + } + .fullScreenCover(isPresented: $showingCamera) { + CameraPicker { photo.load($0) }.ignoresSafeArea() + } + .fileImporter(isPresented: $showingFiles, allowedContentTypes: [.image]) { result in + guard case let .success(url) = result, url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + if let data = try? Data(contentsOf: url) { + load(data) + } + } + .onChange(of: pickedItem) { _, item in + guard let item else { return } + Task { + importing = true + defer { + importing = false + pickedItem = nil + } + guard let data = try? await item.loadTransferable(type: Data.self) else { + importFailed = true + return + } + load(data) + } + } + .onChange(of: proControls) { _, pro in + if !pro { + photo.applyLook() + } + } + } + + private func load(_ data: Data) { + guard let image = ImageDecoding.cgImage(from: data) else { return } + model.photo.load(image) + } + + private var emptyState: some View { + ContentUnavailableView { + Label("What Are We Printing?", systemImage: "photo.badge.plus") + } description: { + Text("Every dot you see on screen is a dot on paper: 384 across, no surprises.") + } actions: { + Button { + showingPhotos = true + } label: { + Label("Choose Photo", systemImage: "photo.on.rectangle").padding(.horizontal, 8) + } + .buttonStyle(.glassProminent) + .controlSize(.large) + + HStack(spacing: 12) { + Button("Camera", systemImage: "camera") { showingCamera = true } + Button("Files", systemImage: "folder") { showingFiles = true } + PasteButton(supportedContentTypes: [.image]) { providers in + _ = providers.first?.loadDataRepresentation(for: .image) { data, _ in + guard let data else { return } + Task { @MainActor in load(data) } + } + } + .buttonBorderShape(.capsule) + .labelStyle(.iconOnly) + } + .buttonStyle(.glass) + } + } + + @ToolbarContentBuilder + private var editingToolbar: some ToolbarContent { + let photo = model.photo + + ToolbarItem(placement: .topBarLeading) { + Menu("Picture", systemImage: "photo.badge.plus") { + Button("Choose Photo", systemImage: "photo.on.rectangle") { showingPhotos = true } + Button("Camera", systemImage: "camera") { showingCamera = true } + Button("Files", systemImage: "folder") { showingFiles = true } + Divider() + Button("Remove", systemImage: "trash", role: .destructive) { + withAnimation(.smooth) { photo.clear() } + } + } + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Rotate", systemImage: "rotate.right") { + photo.settings.rotation = (photo.settings.rotation + 1) % 4 + } + Button( + "Invert", + systemImage: photo.settings.tone.inverted + ? "circle.righthalf.filled.inverse" : "circle.lefthalf.filled" + ) { + photo.settings.tone.inverted.toggle() + } + } + ToolbarSpacer(.fixed, placement: .topBarTrailing) + ToolbarItem(placement: .topBarTrailing) { + Toggle("All Controls", systemImage: "slider.horizontal.3", isOn: $proControls.animation(.smooth)) + } + } +} diff --git a/Inkless/UI/PrinterBar.swift b/Inkless/UI/PrinterBar.swift new file mode 100644 index 0000000..22117f9 --- /dev/null +++ b/Inkless/UI/PrinterBar.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct PrinterBar: View { + @Environment(AppModel.self) private var model + @Environment(PrinterManager.self) private var printer + @State private var appeared = false + + private var length: String? { + guard appeared, !printer.isPrinting, let printable = model.printable else { return nil } + return PaperLength.string(printable.lengthInMillimetres) + } + + var body: some View { + HStack(spacing: 0) { + PrinterStatus().frame(maxWidth: .infinity) + + if let length { + Button { + model.print() + } label: { + Text("Print · \(length)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .contentTransition(.numericText()) + .padding(.leading, 16) + .padding(.trailing, 20) + .frame(maxHeight: .infinity) + .background { Rectangle().fill(.tint).padding(.trailing, -80) } + .contentShape(.rect) + } + .buttonStyle(.plain) + .disabled(printer.hasRememberedPrinter && printer.link != .ready) + .accessibilityLabel("Print, \(length) of paper") + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .frame(height: 48) + .clipShape(.capsule) + .glassEffect(.regular.interactive(), in: .capsule) + .padding(.horizontal, 16) + .padding(.bottom, 12) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + .animation(.bouncy(duration: 0.5, extraBounce: 0.1), value: length) + .onAppear { appeared = true } + } +} diff --git a/Inkless/UI/PrinterScreen.swift b/Inkless/UI/PrinterScreen.swift new file mode 100644 index 0000000..a35cb03 --- /dev/null +++ b/Inkless/UI/PrinterScreen.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct PrinterScreen: View { + @Environment(AppModel.self) private var model + @Environment(PrinterManager.self) private var printer + @State private var confirmingForget = false + + var body: some View { + NavigationStack { + Group { + if printer.hasRememberedPrinter { + details + } else { + ContentUnavailableView { + Label("No Printer", systemImage: "printer") + } description: { + Text("Turn your MXW01 on and keep it close to your iPhone.") + } actions: { + Button("Find Printer") { model.isPairing = true } + .buttonStyle(.glassProminent) + .controlSize(.large) + } + } + } + .navigationTitle("Printer") + } + } + + private var details: some View { + @Bindable var printer = printer + + return List { + Section { + VStack(spacing: 12) { + PrinterHero(dimmed: printer.link != .ready) + .frame(height: 190) + VStack(spacing: 2) { + Text(printer.printerName ?? "MXW01").font(.title2.weight(.semibold)) + Text(linkDescription).font(.subheadline).foregroundStyle(.secondary) + .contentTransition(.opacity) + } + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .listRowInsets(.init()) + } + + if let status = printer.status { + Section { + LabeledContent { + Text(Double(status.battery) / 100, format: .percent).monospacedDigit() + } label: { + Label("Battery", systemImage: batterySymbol(status.battery)) + } + LabeledContent { + Text( + Measurement(value: Double(status.temperature), unit: UnitTemperature.celsius), + format: .measurement(width: .abbreviated) + ) + .monospacedDigit() + } label: { + Label("Print Head", systemImage: "thermometer.medium") + } + if let fault = status.fault { + Label(fault.title, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + } + + Section { + VStack(alignment: .leading, spacing: 10) { + Text("Darkness") + Slider(value: $printer.density, in: 0 ... 1) { + Text("Darkness") + } minimumValueLabel: { + Image(systemName: "circle.dotted").foregroundStyle(.secondary) + } maximumValueLabel: { + Image(systemName: "circle.fill").foregroundStyle(.secondary) + } + } + Button("Feed Paper", systemImage: "arrow.up.to.line") { printer.feedPaper() } + .disabled(printer.link != .ready || printer.isPrinting) + } footer: { + Text("Darker prints have more contrast but heat the head faster. Above 60 °C the heat is reduced automatically.") + } + + Section { + if let firmware = printer.firmware { + LabeledContent("Firmware", value: firmware) + } + LabeledContent("Print Width", value: "48 mm · 384 dots") + Button("Forget This Printer", role: .destructive) { confirmingForget = true } + } + } + .confirmationDialog("Forget this printer?", isPresented: $confirmingForget, titleVisibility: .visible) { + Button("Forget", role: .destructive) { withAnimation { printer.forgetPrinter() } } + } message: { + Text("You will need to connect it again before printing.") + } + .refreshable { printer.refreshStatus() } + } + + private var linkDescription: String { + switch printer.link { + case .ready: printer.isPrinting ? "Printing" : "Connected" + case .bluetoothOff: "Bluetooth is off" + case .unauthorized: "No Bluetooth access" + default: "Out of range · reconnects on its own" + } + } + + private func batterySymbol(_ level: Int) -> String { + switch level { + case ..<13: "battery.0percent" + case ..<38: "battery.25percent" + case ..<63: "battery.50percent" + case ..<88: "battery.75percent" + default: "battery.100percent" + } + } +} + +struct PrinterHero: View { + var dimmed = false + + var body: some View { + GeometryReader { geometry in + let side = min(geometry.size.width, geometry.size.height) + + Image(.printer) + .resizable() + .scaledToFit() + .frame(width: side, height: side) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .saturation(dimmed ? 0.15 : 1) + .opacity(dimmed ? 0.7 : 1) + .animation(.smooth, value: dimmed) + } + .accessibilityHidden(true) + } +} diff --git a/Inkless/UI/RootView.swift b/Inkless/UI/RootView.swift new file mode 100644 index 0000000..9d9bbb4 --- /dev/null +++ b/Inkless/UI/RootView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct RootView: View { + @Environment(AppModel.self) private var model + @Environment(PrinterManager.self) private var printer + @Environment(\.colorScheme) private var colorScheme + @State private var offered: Set = [] + + var body: some View { + @Bindable var model = model + + TabView(selection: $model.section) { + Tab("Photo", systemImage: "photo", value: AppModel.Section.photo) { + PhotoScreen() + } + Tab("Text", systemImage: "character.cursor.ibeam", value: AppModel.Section.text) { + TextScreen() + } + Tab("Printer", systemImage: "printer", value: AppModel.Section.printer) { + PrinterScreen() + } + } + .tint(model.photo.accentHue.map { ImageAccent.color(hue: $0, dark: colorScheme == .dark) }) + .animation(.smooth(duration: 0.6), value: model.photo.accentHue) + .sheet(isPresented: $model.isPairing) { + PairingSheet() + } + .alert(isPresented: failureBinding, error: model.failure) { _ in + Button("OK", role: .cancel) {} + } message: { error in + Text(error.recoverySuggestion ?? "") + } + .sensoryFeedback(.success, trigger: model.completedJobs) + .sensoryFeedback(.error, trigger: model.failure) { _, new in new != nil } + .task { printer.activate() } + .onChange(of: printer.discovered) { _, devices in + offered.formIntersection(devices.map(\.id)) + if model.isPairing { + offered.formUnion(devices.map(\.id)) + } + guard !printer.hasRememberedPrinter, !model.isPairing else { return } + let known = printer.connectedThisSession.union(offered) + guard let fresh = devices.first(where: { !known.contains($0.id) }) else { return } + offered.insert(fresh.id) + model.isPairing = true + } + } + + private var failureBinding: Binding { + Binding { model.failure != nil } set: { + if !$0 { + model.failure = nil + } + } + } +} + +struct PrinterStatus: View { + @Environment(AppModel.self) private var model + @Environment(PrinterManager.self) private var printer + var body: some View { + HStack(spacing: 12) { + if let job = printer.job { + progress(job) + } else { + state + if !printer.hasRememberedPrinter { + Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) + } + } + } + .padding(.leading, 16) + .padding(.trailing, 14) + .animation(.smooth, value: printer.link) + .animation(.smooth, value: printer.isPrinting) + } + + private var state: some View { + Button { + if printer.link == .ready { + model.section = .printer + } else { + model.isPairing = true + } + } label: { + HStack(spacing: 10) { + LinkGlyph(link: printer.link, fault: printer.status?.fault != nil, nearby: nearby != nil) + VStack(alignment: .leading, spacing: 0) { + Text(printer.printerName ?? nearby?.name ?? "No Printer").font(.subheadline.weight(.semibold)) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + .contentTransition(.numericText()) + } + .lineLimit(1) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 44) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityHint(printer.link == .ready ? "Opens printer details" : "Looks for a printer") + } + + private var nearby: PrinterManager.Discovered? { + printer.hasRememberedPrinter ? nil : printer.discovered.first + } + + private var subtitle: String { + if !printer.hasRememberedPrinter { + return nearby == nil ? "Keep your MXW01 nearby" : "Nearby · Tap to connect" + } + switch printer.link { + case .bluetoothOff: return "Bluetooth is off" + case .unauthorized: return "No Bluetooth access" + case .idle, .scanning, .connecting: return "Searching…" + case .ready: + guard let status = printer.status else { return "Ready" } + if let fault = status.fault { + return fault.title + } + return "Ready · \(status.battery)%" + } + } + + private func progress(_ job: PrinterManager.Job) -> some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(job.phase == .finishing ? "Printing…" : "Sending…") + .font(.subheadline.weight(.semibold)) + ProgressView(value: job.progress) + } + Button("Cancel", systemImage: "xmark") { printer.cancelPrint() } + .labelStyle(.iconOnly) + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .frame(minWidth: 44, minHeight: 44) + .contentShape(.rect) + } + .transition(.blurReplace) + } +} + +struct LinkGlyph: View { + let link: PrinterManager.Link + let fault: Bool + var nearby = false + + var body: some View { + Image(systemName: symbol) + .font(.title3) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(style) + .symbolEffect(.variableColor.iterative, options: .repeat(.continuous), isActive: searching && !nearby) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 28) + .accessibilityHidden(true) + } + + private var searching: Bool { + link == .connecting || link == .scanning + } + + private var symbol: String { + switch link { + case .bluetoothOff, .unauthorized: "antenna.radiowaves.left.and.right.slash" + case .idle, .scanning, .connecting: "dot.radiowaves.left.and.right" + case .ready: fault ? "exclamationmark.triangle.fill" : "printer.fill" + } + } + + private var style: AnyShapeStyle { + guard link == .ready else { return nearby ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary) } + return fault ? AnyShapeStyle(.red) : AnyShapeStyle(.tint) + } +} + +enum PaperLength { + static func string(_ millimetres: Double) -> String { + let unit: UnitLength = millimetres < 100 ? .millimeters : .centimeters + let value = Measurement(value: millimetres, unit: UnitLength.millimeters).converted(to: unit) + let digits = unit == .millimeters ? 0 : 1 + return value.formatted(.measurement( + width: .abbreviated, usage: .asProvided, numberFormatStyle: .number.precision(.fractionLength(digits)) + )) + } +} diff --git a/Inkless/UI/RulerSlider.swift b/Inkless/UI/RulerSlider.swift new file mode 100644 index 0000000..fa44bf9 --- /dev/null +++ b/Inkless/UI/RulerSlider.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct RulerSlider: View { + @Binding var value: Double + var range: ClosedRange = -1 ... 1 + var steps = 40 + + @State private var dragStart: Double? + @ScaledMetric private var height = 44.0 + + private let tickSpacing: CGFloat = 9 + + var body: some View { + let span = range.upperBound - range.lowerBound + let stepValue = span / Double(steps) + let position = (value - range.lowerBound) / stepValue + + Canvas { context, size in + let centre = size.width / 2 + let origin = (0 - range.lowerBound) / stepValue + for index in 0 ... steps { + let x = centre + (CGFloat(index) - CGFloat(position)) * tickSpacing + guard x > -2, x < size.width + 2 else { continue } + let major = (index - Int(origin.rounded())).isMultiple(of: 5) + let fade = 1 - pow(abs(x - centre) / centre, 2) + var tick = Path() + tick.move(to: CGPoint(x: x, y: size.height - (major ? 18 : 11))) + tick.addLine(to: CGPoint(x: x, y: size.height - 2)) + context.stroke( + tick, + with: .color(.primary.opacity((major ? 0.75 : 0.35) * fade)), + style: StrokeStyle(lineWidth: major ? 1.5 : 1, lineCap: .round) + ) + } + if range.contains(0) { + let zero = centre + (CGFloat(origin) - CGFloat(position)) * tickSpacing + context.fill( + Path(ellipseIn: CGRect(x: zero - 2, y: size.height - 26, width: 4, height: 4)), + with: .color(.secondary) + ) + } + } + .frame(height: height) + .overlay(alignment: .bottom) { + Capsule().fill(.tint).frame(width: 3, height: 26) + } + .contentShape(.rect) + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { drag in + let start = dragStart ?? value + dragStart = start + let moved = Double(-drag.translation.width / tickSpacing) * stepValue + let snapped = ((start + moved) / stepValue).rounded() * stepValue + value = min(max(snapped, range.lowerBound), range.upperBound) + } + .onEnded { _ in dragStart = nil } + ) + .sensoryFeedback(.selection, trigger: Int((value / stepValue).rounded())) + .accessibilityRepresentation { + Slider(value: $value, in: range, step: stepValue) + } + } +} diff --git a/Inkless/UI/TextScreen.swift b/Inkless/UI/TextScreen.swift new file mode 100644 index 0000000..c570829 --- /dev/null +++ b/Inkless/UI/TextScreen.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct TextScreen: View { + @Environment(AppModel.self) private var model + @FocusState private var editing: Bool + + var body: some View { + let text = model.text + + NavigationStack { + ZStack { + if let preview = text.preview { + PaperStage(image: preview) + .onTapGesture { editing = false } + } else { + ContentUnavailableView { + Label("A Note on Paper", systemImage: "text.alignleft") + } description: { + Text("A shopping list, a reminder, a gift tag. Text prints crisp, with no grain.") + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .safeAreaBar(edge: .bottom, spacing: 0) { + VStack(spacing: 10) { + composer + PrinterBar() + } + } + .navigationTitle(text.preview == nil ? "Text" : "") + .toolbarTitleDisplayMode(text.preview == nil ? .large : .inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { editing = false }.fontWeight(.semibold) + } + if !text.text.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Button("Clear", systemImage: "trash") { + withAnimation(.smooth) { text.text = "" } + } + } + } + } + } + } + + private var composer: some View { + @Bindable var text = model.text + + return VStack(spacing: 10) { + HStack(spacing: 12) { + Menu { + Picker("Font", selection: $text.style.face) { + ForEach(TextStyle.Face.allCases) { face in + Text(face.title).tag(face) + } + } + Toggle("Bold", systemImage: "bold", isOn: $text.style.bold) + Toggle("Checklist", systemImage: "checklist", isOn: $text.style.checklist) + } label: { + Image(systemName: "textformat") + .frame(width: 28, height: 28) + } + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .accessibilityLabel("Format") + + Picker("Alignment", selection: $text.style.alignment) { + Image(systemName: "text.alignleft").tag(NSTextAlignment.left) + .accessibilityLabel("Left") + Image(systemName: "text.aligncenter").tag(NSTextAlignment.center) + .accessibilityLabel("Centre") + } + .pickerStyle(.segmented) + .frame(width: 104) + + Spacer(minLength: 0) + + Stepper("Size", value: $text.style.size, in: 20 ... 96, step: 4) + .labelsHidden() + .accessibilityValue(Text("\(Int(text.style.size / PrintHead.dotsPerMillimetre)) millimetres")) + } + .frame(minHeight: 44) + + TextField("Text to print", text: $text.text, axis: .vertical) + .lineLimit(1 ... 5) + .focused($editing) + .padding(.horizontal, 16) + .padding(.vertical, 11) + .glassEffect(.regular, in: .rect(cornerRadius: 22, style: .continuous)) + } + .padding(.horizontal, 16) + .padding(.top, 10) + .sensoryFeedback(.selection, trigger: text.style) + } +} diff --git a/InklessTests/ImagingTests.swift b/InklessTests/ImagingTests.swift new file mode 100644 index 0000000..b30e2b8 --- /dev/null +++ b/InklessTests/ImagingTests.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import Inkless +import Testing + +struct ImagingTests { + @Test func packsLeftmostDotIntoLowestBit() { + var dots = [UInt8](repeating: 0, count: 16) + dots[0] = 1 + dots[9] = 1 + let bitmap = DotBitmap(width: 16, height: 1, dots: dots) + #expect([UInt8](bitmap.packedRows()) == [0b0000_0001, 0b0000_0010]) + } + + @Test func packsGrayAsInvertedNibblesHighFirst() { + let bitmap = GrayBitmap(width: 4, height: 1, luma: [255, 0, 128, 16]) + #expect([UInt8](bitmap.packedNibbles()) == [0x0F, 0x7E]) + } + + @Test func padsShortJobsWithPaper() { + let raster = PrintRaster.dots(.blank(rows: 10)).padded(toMinimumHeight: 90) + #expect(raster.height == 90) + #expect(raster.blackRatio == 0) + } + + @Test(arguments: DitherAlgorithm.allCases) + func ditherKeepsExtremesAndAverageTone(algorithm: DitherAlgorithm) { + let side = 64 + let white = algorithm.apply(to: .init(repeating: 1, count: side * side), width: side, height: side) + let black = algorithm.apply(to: .init(repeating: 0, count: side * side), width: side, height: side) + #expect(white.allSatisfy { $0 == 0 }) + #expect(black.allSatisfy { $0 == 1 }) + + guard algorithm != .threshold else { return } + let grey = algorithm.apply(to: .init(repeating: 0.5, count: side * side), width: side, height: side) + let ratio = Double(grey.reduce(0) { $0 + Int($1) }) / Double(grey.count) + #expect(abs(ratio - 0.5) < 0.15) + } + + @Test func textRendersToHeadWidth() throws { + let raster = try #require(TextRenderer.render("Milk\nBread", style: TextStyle())) + #expect(raster.width == PrintHead.dots) + #expect(raster.blackRatio > 0) + } +} diff --git a/InklessTests/ProtocolTests.swift b/InklessTests/ProtocolTests.swift new file mode 100644 index 0000000..59ca5a2 --- /dev/null +++ b/InklessTests/ProtocolTests.swift @@ -0,0 +1,59 @@ +import Foundation +@testable import Inkless +import Testing + +struct ProtocolTests { + @Test func crcMatchesVendorFrames() { + #expect(MXW.crc8([0x5D]) == 0x94) + #expect(MXW.crc8([0x00]) == 0x00) + } + + @Test func framesMatchVendorBytes() { + #expect(MXW.density(93).hexString == "2221A20001005D94FF") + #expect(MXW.startPrint(rows: 384, mode: .dots).hexString == "2221A9000400800130000000") + #expect(MXW.startPrint(rows: 384, mode: .gray).hexString == "2221A9000400800130020000") + #expect(MXW.flush().hexString == "2221AD000100000000") + #expect(MXW.cancel().hexString == "2221AC0001000000FF") + #expect(MXW.versionRequest().hexString == "2221B10001000000FF") + } + + @Test func challengeReplyMatchesReferenceEncoder() throws { + let challenge: [UInt8] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99] + let reply = try #require(MXW.challengeResponse(to: challenge, now: Date(timeIntervalSince1970: 1_234_567_895.678))) + #expect(reply.hexString == "2221B3002200834EF9578D0451A26B80DAD95BAEDFC6352026B9F2D7C0D3CB1702488E5738E4868D00FF") + } + + @Test func parsesStatus() throws { + let data = try #require(Data(hexString: "2221A1010900" + "01" + "0000" + "57" + "1F" + "00" + "01" + "04" + "00" + "00FF")) + let expected = MXW.Status(activity: .printing, battery: 87, temperature: 31, fault: .overheated) + #expect(MXW.parse(data) == .status(expected)) + } + + @Test func parsesBothFlowControlForms() throws { + #expect(try MXW.parse(#require(Data(hexString: "2221AE0101001070FF"))) == .flowPause) + #expect(try MXW.parse(#require(Data(hexString: "2221AE0101000000FF"))) == .flowResume) + #expect(try MXW.parse(#require(Data(hexString: "2221AE0001000000"))) == .flowPause) + #expect(try MXW.parse(#require(Data(hexString: "2221AE0001001000"))) == .flowResume) + } + + @Test func parsesFirmwareAndHeadType() throws { + let text = "MXW 2.1 2" + let payload = text.utf8.map { String(format: "%02X", $0) }.joined() + let data = try #require(Data(hexString: "2221B1010900" + payload + "00FF")) + #expect(MXW.parse(data) == .version(text, .highVoltage)) + } + + @Test func grayDepthFollowsFirmware() { + #expect(MXW.usesFourBitGray(firmware: nil)) + #expect(MXW.usesFourBitGray(firmware: "MXW 2.1 2")) + #expect(!MXW.usesFourBitGray(firmware: "MXW 1.9 1")) + #expect(!MXW.usesFourBitGray(firmware: "MXW 2.0 1")) + } + + @Test func densityDropsOnlyWhenHot() { + let cool = MXW.compensatedDensity(requested: 93, temperature: 40, blackRatio: 0.8, head: .highVoltage, mode: .dots) + let hot = MXW.compensatedDensity(requested: 93, temperature: 66, blackRatio: 0.8, head: .highVoltage, mode: .dots) + #expect(cool == 93) + #expect(hot == 5) + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6526a78 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +SCHEME := Inkless +DESTINATION ?= platform=iOS Simulator,name=iPhone 17 Pro +XCODEBUILD := xcodebuild -project Inkless.xcodeproj -scheme $(SCHEME) -derivedDataPath build -quiet + +.PHONY: fmt check test art + +fmt: + swiftformat . + +check: + swiftformat --lint . + swiftlint --strict --quiet + $(XCODEBUILD) -destination 'generic/platform=iOS Simulator' build + +test: + $(XCODEBUILD) -destination '$(DESTINATION)' test + +art: + for s in 1 2 3; do rsvg-convert -w $$((240*s)) -h $$((240*s)) Design/printer.svg -o Inkless/Assets.xcassets/Printer.imageset/printer@$${s}x.png; done diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3bb526 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Inkless + +A native iPhone app for the MXW01 "cat" thermal printer. It talks to the printer directly over Bluetooth: no account, no server, no ads. + +

+ Photo + Text + Printer +

+ +Photos with 16 dithering styles or HD grayscale, text notes and checklists. The preview is drawn dot for dot, so what you see is what prints. + +## Build + +Needs Xcode 26+, iOS 26+. + +```sh +cp Config/Signing.example.xcconfig Config/Signing.xcconfig # set your team and bundle prefix +open Inkless.xcodeproj +``` + +`make fmt`, `make check`, `make test`. diff --git a/Screenshots/photo.png b/Screenshots/photo.png new file mode 100644 index 0000000..226c9d4 Binary files /dev/null and b/Screenshots/photo.png differ diff --git a/Screenshots/printer.png b/Screenshots/printer.png new file mode 100644 index 0000000..4a23ba6 Binary files /dev/null and b/Screenshots/printer.png differ diff --git a/Screenshots/text.png b/Screenshots/text.png new file mode 100644 index 0000000..18cb3ef Binary files /dev/null and b/Screenshots/text.png differ