feat(*): inkless app for the mxw01 thermal printer

This commit is contained in:
hh committed 2026-09-19 23:28:18 +02:00
commit 0c36cc115e
50 files changed
+4028

No files matched your search

+32
View File
@@ -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
+13
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
--swiftversion 6.0
--exclude build,research
--maxwidth 140
--wraparguments before-first
--wrapcollections before-first
--self remove
--commas always
--disable wrapMultilineStatementBraces
+24
View File
@@ -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
+16
View File
@@ -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
```
+2
View File
@@ -0,0 +1,2 @@
BUNDLE_ID_PREFIX = com.example
#include? "Signing.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
DEVELOPMENT_TEAM = ABCDE12345
BUNDLE_ID_PREFIX = com.yourname
+44
View File
@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 760" width="760" height="760">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF"/><stop offset="0.7" stop-color="#F6F7F9"/><stop offset="1" stop-color="#E4E7EC"/>
</linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#F3DFA0"/><stop offset="0.5" stop-color="#C9A24B"/><stop offset="1" stop-color="#EBD08A"/>
</linearGradient>
<linearGradient id="disc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF"/><stop offset="1" stop-color="#ECEEF2"/>
</linearGradient>
<clipPath id="ringClip"><circle cx="380" cy="412" r="200"/></clipPath>
<filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="46"/></filter>
<filter id="soft" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="9"/></filter>
<filter id="ground" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="16"/></filter>
</defs>
<ellipse cx="380" cy="722" rx="250" ry="18" fill="#000" opacity="0.16" filter="url(#ground)"/>
<path d="M175 150 Q191 66 225 68 Q243 70 259 150 Z" fill="#FBF1DF" stroke="url(#gold)" stroke-width="7" stroke-linejoin="round"/>
<path d="M585 150 Q569 66 535 68 Q517 70 501 150 Z" fill="#FBF1DF" stroke="url(#gold)" stroke-width="7" stroke-linejoin="round"/>
<rect x="292" y="700" width="46" height="22" rx="9" fill="#DADDE3"/>
<rect x="422" y="700" width="46" height="22" rx="9" fill="#DADDE3"/>
<rect x="86" y="112" width="588" height="600" rx="150" fill="url(#body)" stroke="#DDE0E6" stroke-width="2"/>
<g clip-path="url(#ringClip)">
<rect x="160" y="190" width="440" height="440" fill="#BFE9F2"/>
<g filter="url(#blur)">
<circle cx="500" cy="270" r="120" fill="#FF7FB5"/>
<circle cx="570" cy="430" r="110" fill="#19D3C5"/>
<circle cx="440" cy="590" r="120" fill="#7DEB9A"/>
<circle cx="230" cy="520" r="120" fill="#B9A8F2"/>
<circle cx="240" cy="300" r="120" fill="#8FE3EE"/>
</g>
</g>
<circle cx="380" cy="412" r="202" fill="none" stroke="url(#gold)" stroke-width="7"/>
<circle cx="380" cy="440" r="132" fill="#000" opacity="0.18" filter="url(#soft)"/>
<path d="M300 342 Q296 282 322 282 Q338 282 352 318 Z" fill="url(#disc)"/>
<path d="M460 342 Q464 282 438 282 Q422 282 408 318 Z" fill="url(#disc)"/>
<circle cx="380" cy="430" r="130" fill="url(#disc)"/>
<circle cx="380" cy="430" r="104" fill="none" stroke="#E3E6EB" stroke-width="3"/>
<g stroke="#C9CDD4" stroke-width="5" stroke-linecap="round" fill="none">
<path d="M380 478 V492"/><path d="M380 492 L368 502"/><path d="M380 492 L392 502"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+356
View File
@@ -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 = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A10000000000000000000002 /* Inkless */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Inkless;
sourceTree = "<group>";
};
A10000000000000000000021 /* InklessTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = InklessTests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
A10000000000000000000005 /* Products */ = {
isa = PBXGroup;
children = (
A10000000000000000000001 /* Inkless.app */,
A10000000000000000000020 /* InklessTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion = "2700" version = "1.7">
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES">
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000006" BuildableName = "Inkless.app" BlueprintName = "Inkless" ReferencedContainer = "container:Inkless.xcodeproj"></BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference skipped = "NO" parallelizable = "YES">
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000025" BuildableName = "InklessTests.xctest" BlueprintName = "InklessTests" ReferencedContainer = "container:Inkless.xcodeproj"></BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000006" BuildableName = "Inkless.app" BlueprintName = "Inkless" ReferencedContainer = "container:Inkless.xcodeproj"></BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000006" BuildableName = "Inkless.app" BlueprintName = "Inkless" ReferencedContainer = "container:Inkless.xcodeproj"></BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug"></AnalyzeAction>
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"></ArchiveAction>
</Scheme>
+195
View File
@@ -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<Void, Never>?
@ObservationIgnored private var proofTask: Task<Void, Never>?
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<Void, Never>?
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)
}
}
+53
View File
@@ -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
}
+59
View File
@@ -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
}
}
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset="1" stop-color="#E6EAF2"/></linearGradient></defs><path transform="rotate(-8 296 640)" d="M244 650 Q256 545 296 524 Q336 545 348 650 Z" fill="url(#b)"/><path transform="rotate(8 728 640)" d="M676 650 Q688 545 728 524 Q768 545 780 650 Z" fill="url(#b)"/><rect x="172" y="610" width="680" height="640" rx="190" fill="url(#b)"/><rect x="306" y="638" width="412" height="28" rx="14" fill="#17172B"/></svg>

After

Width:  |  Height:  |  Size: 602 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024"><path d="M346 172 Q346 150 368 150 H656 Q678 150 678 172 V652 H346 Z" fill="#FFFFFF"/><circle cx="398" cy="250" r="32.0" fill="#17172B"/><circle cx="474" cy="250" r="28.1" fill="#17172B"/><circle cx="550" cy="250" r="24.3" fill="#17172B"/><circle cx="626" cy="250" r="20.4" fill="#17172B"/><circle cx="398" cy="326" r="26.2" fill="#17172B"/><circle cx="474" cy="326" r="22.3" fill="#17172B"/><circle cx="550" cy="326" r="18.5" fill="#17172B"/><circle cx="626" cy="326" r="14.6" fill="#17172B"/><circle cx="398" cy="402" r="20.4" fill="#17172B"/><circle cx="474" cy="402" r="16.5" fill="#17172B"/><circle cx="550" cy="402" r="12.7" fill="#17172B"/><circle cx="626" cy="402" r="8.8" fill="#17172B"/><circle cx="398" cy="478" r="14.6" fill="#17172B"/><circle cx="474" cy="478" r="10.7" fill="#17172B"/><circle cx="550" cy="478" r="6.9" fill="#17172B"/></svg>

After

Width:  |  Height:  |  Size: 947 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024"><defs><linearGradient id="r" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#FF6FAE"/><stop offset="0.4" stop-color="#9B8CFF"/><stop offset="0.72" stop-color="#22C7D6"/><stop offset="1" stop-color="#5BE38F"/></linearGradient></defs><circle cx="512" cy="960" r="176" fill="none" stroke="url(#r)" stroke-width="64"/></svg>

After

Width:  |  Height:  |  Size: 422 B

+83
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
@@ -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 } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+281
View File
@@ -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
}
}
+75
View File
@@ -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))
}
}
+134
View File
@@ -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)
}
}
+151
View File
@@ -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)
}
}
}
}
+87
View File
@@ -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,
])
}
}
+233
View File
@@ -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>) -> 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<SHA256>.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()
}
}
+48
View File
@@ -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)"
}
}
}
@@ -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
}
}
@@ -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) } }
}
}
}
}
+430
View File
@@ -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<UUID> = []
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<Void, Never>?
@ObservationIgnored private var flowHeld = false
@ObservationIgnored var lastSeen: [UUID: Date] = [:]
@ObservationIgnored var presenceTask: Task<Void, Never>?
@ObservationIgnored private var sentFraction = 0.0
@ObservationIgnored private var flowResumed: CheckedContinuation<Void, Never>?
@ObservationIgnored private var headType = MXW.HeadType.unknown
@ObservationIgnored private var printTask: Task<Void, Error>?
@ObservationIgnored private var statusPoll: Task<Void, Never>?
private struct Waiter {
let id: UUID
let match: (MXW.Notification) -> Bool
let continuation: CheckedContinuation<MXW.Notification, Error>
}
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, PrintError>) -> 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()
}
}
}
+56
View File
@@ -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()
}
}
}
+144
View File
@@ -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)
}
}
+200
View File
@@ -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)
}
}
+256
View File
@@ -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<Double> {
self == .sharpness ? 0 ... 1 : -1 ... 1
}
var keyPath: WritableKeyPath<ToneAdjustments, Double> {
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 : [])
}
}
}
+152
View File
@@ -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))
}
}
}
+46
View File
@@ -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 }
}
}
+142
View File
@@ -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)
}
}
+186
View File
@@ -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<UUID> = []
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<Bool> {
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))
))
}
}
+64
View File
@@ -0,0 +1,64 @@
import SwiftUI
struct RulerSlider: View {
@Binding var value: Double
var range: ClosedRange<Double> = -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)
}
}
}
+98
View File
@@ -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)
}
}
+44
View File
@@ -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)
}
}
+59
View File
@@ -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)
}
}
+19
View File
@@ -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
+22
View File
@@ -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.
<p>
<img src="Screenshots/photo.png" width="30%" alt="Photo">
<img src="Screenshots/text.png" width="30%" alt="Text">
<img src="Screenshots/printer.png" width="30%" alt="Printer">
</p>
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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB