Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion crates/shift-source/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ pub struct ShiftSourcePackage {
}

impl ShiftSourcePackage {
pub fn is_package_path(path: impl AsRef<Path>) -> bool {
path.as_ref()
.extension()
.and_then(|extension| extension.to_str())
== Some("shift")
}

pub fn create_empty(path: impl AsRef<Path>) -> Result<Self, SourcePackageError> {
let path = path.as_ref();
validate_shift_extension(path)?;
Expand Down Expand Up @@ -79,7 +86,7 @@ impl ShiftSourcePackage {
}

fn validate_shift_extension(path: &Path) -> Result<(), SourcePackageError> {
if path.extension().and_then(|extension| extension.to_str()) == Some("shift") {
if ShiftSourcePackage::is_package_path(path) {
Ok(())
} else {
Err(SourcePackageError::InvalidExtension(path.to_path_buf()))
Expand Down
1 change: 1 addition & 0 deletions crates/shift-workspace/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"

[dependencies]
shift-backends = { workspace = true }
shift-font = { workspace = true }
shift-source = { workspace = true }
shift-store = { workspace = true }
Expand Down
7 changes: 5 additions & 2 deletions crates/shift-workspace/docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ crates/shift-workspace/src/

- `FontWorkspace` -- live backend object for one open font project.
- `NewWorkspace` -- options used when creating a new source package and working store.
- `WorkspaceSource` -- explicit source state: saved `.shift` package or imported external file.
- `WorkspaceError` -- source-package and store failures.

## How it works

`FontWorkspace::create_new(source_path, store_path, options)` creates a placeholder `.shift` package, opens the working SQLite store, writes initial font metadata, and starts with an empty `shift-font::Font`.
`FontWorkspace::create(source_path, store_path, options)` creates a placeholder `.shift` package, opens the working SQLite store, writes initial font metadata, and starts with an empty `shift-font::Font`.

`FontWorkspace::open(source_path, store_path)` validates the existing source package, opens the working store, and starts with an empty in-memory font until source/store hydration is implemented.
`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. Other supported font paths are imported through `shift-backends` into an unsaved workspace with no save target.

`FontWorkspace::save()` succeeds for saved `.shift` workspaces and returns `NeedsSaveAs` for imported workspaces. `save_as(path)` creates a `.shift` package and makes it the save target.

## Verification

Expand Down
2 changes: 1 addition & 1 deletion crates/shift-workspace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ mod new_workspace;
mod workspace;

pub use new_workspace::NewWorkspace;
pub use workspace::{FontWorkspace, WorkspaceError};
pub use workspace::{FontWorkspace, WorkspaceError, WorkspaceSource};
129 changes: 121 additions & 8 deletions crates/shift-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::path::Path;
use std::path::{Path, PathBuf};

use shift_backends::{FontExportRequest, FontExportResult, FontExporter, font_loader::FontLoader};
use shift_source::ShiftSourcePackage;
use shift_store::ShiftStore;

Expand All @@ -12,16 +13,34 @@ pub enum WorkspaceError {

#[error("store error")]
Store(#[from] shift_store::StoreError),

#[error("font backend error")]
Backend(#[from] shift_backends::BackendError),

#[error("font export error")]
Export(#[from] shift_backends::ExportError),

#[error("workspace needs a save path")]
NeedsSaveAs,

#[error("invalid UTF-8 in workspace path: {0}")]
InvalidPathUtf8(PathBuf),
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorkspaceSource {
Package { path: PathBuf },
Imported { original_path: PathBuf },
}

pub struct FontWorkspace {
font: shift_font::Font,
source_package: ShiftSourcePackage,
source: WorkspaceSource,
store: ShiftStore,
}

impl FontWorkspace {
pub fn create_new(
pub fn create(
source_path: impl AsRef<Path>,
store_path: impl AsRef<Path>,
new_workspace: NewWorkspace,
Expand All @@ -30,23 +49,89 @@ impl FontWorkspace {
let mut store = ShiftStore::open(store_path)?;
store.set_font_info(new_workspace.font_info())?;

let mut font = shift_font::Font::new();
font.metadata_mut().family_name = Some(new_workspace.family_name);
font.metrics_mut().units_per_em = new_workspace.units_per_em as f64;

Ok(Self {
font: shift_font::Font::new(),
source_package,
font,
source: WorkspaceSource::Package {
path: source_package.path().to_path_buf(),
},
store,
})
}

pub fn open(
source_path: impl AsRef<Path>,
store_path: impl AsRef<Path>,
) -> Result<Self, WorkspaceError> {
let source_path = source_path.as_ref();
if ShiftSourcePackage::is_package_path(source_path) {
Self::open_package(source_path, store_path)
} else {
Self::import_font(source_path, store_path)
}
}

pub fn save(&mut self) -> Result<(), WorkspaceError> {
match &self.source {
WorkspaceSource::Package { path } => {
ShiftSourcePackage::open(path)?;
Ok(())
}
WorkspaceSource::Imported { .. } => Err(WorkspaceError::NeedsSaveAs),
}
}

pub fn save_as(&mut self, source_path: impl AsRef<Path>) -> Result<(), WorkspaceError> {
let source_package = ShiftSourcePackage::create_empty(source_path)?;
self.source = WorkspaceSource::Package {
path: source_package.path().to_path_buf(),
};

Ok(())
}

pub fn export(&self, request: FontExportRequest) -> Result<FontExportResult, WorkspaceError> {
FontExporter::new()
.export(&self.font, request)
.map_err(WorkspaceError::from)
}

fn open_package(
source_path: impl AsRef<Path>,
store_path: impl AsRef<Path>,
) -> Result<Self, WorkspaceError> {
let source_package = ShiftSourcePackage::open(source_path)?;
let store = ShiftStore::open(store_path)?;

Ok(Self {
font: shift_font::Font::new(),
source_package,
source: WorkspaceSource::Package {
path: source_package.path().to_path_buf(),
},
store,
})
}

fn import_font(
import_path: impl AsRef<Path>,
store_path: impl AsRef<Path>,
) -> Result<Self, WorkspaceError> {
let import_path = import_path.as_ref();
let import_path_str = import_path
.to_str()
.ok_or_else(|| WorkspaceError::InvalidPathUtf8(import_path.to_path_buf()))?;
let font = FontLoader::new().read_font(import_path_str)?;
let mut store = ShiftStore::open(store_path)?;
store.set_font_info(font_info_from_font(&font))?;

Ok(Self {
font,
source: WorkspaceSource::Imported {
original_path: import_path.to_path_buf(),
},
store,
})
}
Expand All @@ -55,8 +140,15 @@ impl FontWorkspace {
&self.font
}

pub fn source_package(&self) -> &ShiftSourcePackage {
&self.source_package
pub fn source(&self) -> &WorkspaceSource {
&self.source
}

pub fn save_target(&self) -> Option<&Path> {
match &self.source {
WorkspaceSource::Package { path } => Some(path),
WorkspaceSource::Imported { .. } => None,
}
}

pub fn store(&self) -> &ShiftStore {
Expand All @@ -71,3 +163,24 @@ impl FontWorkspace {
self.store.get_font_info().map_err(WorkspaceError::from)
}
}

fn font_info_from_font(font: &shift_font::Font) -> shift_store::FontInfo {
let metadata = font.metadata();
shift_store::FontInfo {
family_name: metadata.family_name.clone(),
copyright: metadata.copyright.clone(),
trademark: metadata.trademark.clone(),
description: metadata.description.clone(),
sample_text: None,
designer: metadata.designer.clone(),
designer_url: metadata.designer_url.clone(),
manufacturer: metadata.manufacturer.clone(),
manufacturer_url: metadata.manufacturer_url.clone(),
license_description: metadata.license.clone(),
license_info_url: metadata.license_url.clone(),
vendor_id: None,
version_major: metadata.version_major.map(i64::from),
version_minor: metadata.version_minor.map(i64::from),
units_per_em: font.metrics().units_per_em as i64,
}
}
88 changes: 81 additions & 7 deletions crates/shift-workspace/tests/workspace_test.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
use shift_workspace::{FontWorkspace, NewWorkspace};
use std::path::PathBuf;

use shift_workspace::{FontWorkspace, NewWorkspace, WorkspaceError, WorkspaceSource};

#[test]
fn creates_workspace_with_source_package_and_working_store() {
let temp = tempfile::tempdir().unwrap();
let source_path = temp.path().join("TestFont.shift");
let store_path = temp.path().join("working.sqlite");

let workspace = FontWorkspace::create_new(
let workspace = FontWorkspace::create(
&source_path,
&store_path,
NewWorkspace::with_family_name("Test Font"),
)
.unwrap();

assert_eq!(workspace.source_package().path(), source_path.as_path());
assert!(workspace.source_package().manifest_path().is_file());
assert_eq!(
workspace.source(),
&WorkspaceSource::Package {
path: source_path.clone()
}
);
assert_eq!(workspace.save_target(), Some(source_path.as_path()));
assert!(source_path.join("manifest.json").is_file());
assert_eq!(
workspace
.font_info()
Expand All @@ -24,7 +32,7 @@ fn creates_workspace_with_source_package_and_working_store() {
.as_deref(),
Some("Test Font")
);
assert_eq!(workspace.font().glyphs().len(), 0);
assert!(workspace.font().glyphs().is_empty());
}

#[test]
Expand All @@ -33,10 +41,76 @@ fn opens_existing_workspace_paths() {
let source_path = temp.path().join("TestFont.shift");
let store_path = temp.path().join("working.sqlite");

FontWorkspace::create_new(&source_path, &store_path, NewWorkspace::new()).unwrap();
FontWorkspace::create(&source_path, &store_path, NewWorkspace::new()).unwrap();

let workspace = FontWorkspace::open(&source_path, &store_path).unwrap();

assert_eq!(workspace.source_package().path(), source_path.as_path());
assert_eq!(workspace.save_target(), Some(source_path.as_path()));
assert!(workspace.font_info().unwrap().is_some());
}

#[test]
fn imports_external_fonts_without_a_save_target() {
let temp = tempfile::tempdir().unwrap();
let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace");
let store_path = temp.path().join("working.sqlite");

let workspace = FontWorkspace::open(&source_path, &store_path).unwrap();

assert_eq!(
workspace.source(),
&WorkspaceSource::Imported {
original_path: source_path
}
);
assert_eq!(workspace.save_target(), None);
assert!(!workspace.font().glyphs().is_empty());
assert!(
workspace
.font_info()
.unwrap()
.unwrap()
.family_name
.is_some()
);
}

#[test]
fn save_requires_save_as_for_imported_fonts() {
let temp = tempfile::tempdir().unwrap();
let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace");
let store_path = temp.path().join("working.sqlite");

let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap();

let error = workspace.save().unwrap_err();

assert!(matches!(error, WorkspaceError::NeedsSaveAs));
}

#[test]
fn save_as_assigns_a_shift_package_save_target() {
let temp = tempfile::tempdir().unwrap();
let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace");
let store_path = temp.path().join("working.sqlite");
let save_path = temp.path().join("SavedFont.shift");

let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap();
workspace.save_as(&save_path).unwrap();

assert_eq!(
workspace.source(),
&WorkspaceSource::Package {
path: save_path.clone()
}
);
assert_eq!(workspace.save_target(), Some(save_path.as_path()));
assert!(save_path.join("manifest.json").is_file());
workspace.save().unwrap();
}

fn fixture(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(path)
}
Loading