feat(*): inkless app for the mxw01 thermal printer
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
--swiftversion 6.0
|
||||
--exclude build,research
|
||||
--maxwidth 140
|
||||
--wraparguments before-first
|
||||
--wrapcollections before-first
|
||||
--self remove
|
||||
--commas always
|
||||
--disable wrapMultilineStatementBraces
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
BUNDLE_ID_PREFIX = com.example
|
||||
#include? "Signing.xcconfig"
|
||||
@@ -0,0 +1,2 @@
|
||||
DEVELOPMENT_TEAM = ABCDE12345
|
||||
BUNDLE_ID_PREFIX = com.yourname
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 65 KiB |