From b732336d16063e2949331457a78a5be1c6f7d565 Mon Sep 17 00:00:00 2001
From: Lucas Jansen <7199136+staticintlucas@users.noreply.github.com>
Date: Fri, 22 Dec 2023 01:36:31 +0000
Subject: [PATCH] Misc refactor of keyset-key

---
 keyset-drawing/src/imp/key.rs |  29 +++++--
 keyset-drawing/src/imp/mod.rs |  13 +--
 keyset-drawing/src/lib.rs     |   2 +-
 keyset-drawing/src/pdf.rs     |   2 +-
 keyset-key/src/kle/error.rs   |  12 ++-
 keyset-key/src/kle/mod.rs     | 144 +++++++++++++++++-----------------
 keyset-key/src/legend.rs      |  16 +++-
 keyset-key/src/lib.rs         | 127 +++++++++++++++++++-----------
 keyset-profile/src/lib.rs     |   6 ++
 9 files changed, 212 insertions(+), 139 deletions(-)

diff --git a/keyset-drawing/src/imp/key.rs b/keyset-drawing/src/imp/key.rs
index 7a8af06..81f8ea1 100644
--- a/keyset-drawing/src/imp/key.rs
+++ b/keyset-drawing/src/imp/key.rs
@@ -9,8 +9,12 @@ use super::{Outline, Path, ARC_TOL};
 
 pub fn top(key: &key::Key, options: &Options) -> Path {
     let path = match key.shape {
-        key::Shape::Normal(size) => options.profile.top_with_size(size).to_path(ARC_TOL),
-        key::Shape::SteppedCaps => options.profile.top_with_size((1.25, 1.)).to_path(ARC_TOL),
+        key::Shape::None(..) => BezPath::new(),
+        key::Shape::Normal(size) | key::Shape::Space(size) => {
+            options.profile.top_with_size(size).to_path(ARC_TOL)
+        }
+        key::Shape::Homing(..) => options.profile.top_with_size((1.0, 1.0)).to_path(ARC_TOL),
+        key::Shape::SteppedCaps => options.profile.top_with_size((1.25, 1.0)).to_path(ARC_TOL),
         key::Shape::IsoHorizontal | key::Shape::IsoVertical => iso_top_path(&options.profile),
     };
 
@@ -26,7 +30,14 @@ pub fn top(key: &key::Key, options: &Options) -> Path {
 
 pub fn bottom(key: &key::Key, options: &Options) -> Path {
     let path = match key.shape {
-        key::Shape::Normal(size) => options.profile.bottom_with_size(size).to_path(ARC_TOL),
+        key::Shape::None(..) => BezPath::new(),
+        key::Shape::Normal(size) | key::Shape::Space(size) => {
+            options.profile.bottom_with_size(size).to_path(ARC_TOL)
+        }
+        key::Shape::Homing(..) => options
+            .profile
+            .bottom_with_size((1.0, 1.0))
+            .to_path(ARC_TOL),
         key::Shape::SteppedCaps => options
             .profile
             .bottom_with_size((1.75, 1.))
@@ -47,12 +58,14 @@ pub fn bottom(key: &key::Key, options: &Options) -> Path {
 pub fn homing(key: &key::Key, options: &Options) -> Option<Path> {
     let profile = &options.profile;
 
-    let key::Type::Homing(homing) = key.typ else {
+    let key::Shape::Homing(homing) = key.shape else {
         return None;
     };
     let homing = homing.unwrap_or(profile.homing.default);
 
-    let center = profile.top_with_size(key.shape.margin().size()).center();
+    let center = profile
+        .top_with_size(key.shape.inner_rect().size())
+        .center();
 
     let bez_path = match homing {
         key::Homing::Scoop => None,
@@ -447,7 +460,7 @@ mod tests {
         // Scoop
         let scoop = {
             let mut key = key::Key::example();
-            key.typ = key::Type::Homing(Some(key::Homing::Scoop));
+            key.shape = key::Shape::Homing(Some(key::Homing::Scoop));
             key
         };
 
@@ -457,7 +470,7 @@ mod tests {
         // Bar
         let bar = {
             let mut key = key::Key::example();
-            key.typ = key::Type::Homing(Some(key::Homing::Bar));
+            key.shape = key::Shape::Homing(Some(key::Homing::Bar));
             key
         };
 
@@ -479,7 +492,7 @@ mod tests {
         // Bump
         let bump = {
             let mut key = key::Key::example();
-            key.typ = key::Type::Homing(Some(key::Homing::Bump));
+            key.shape = key::Shape::Homing(Some(key::Homing::Bump));
             key
         };
 
diff --git a/keyset-drawing/src/imp/mod.rs b/keyset-drawing/src/imp/mod.rs
index aeb76d9..cbcccbd 100644
--- a/keyset-drawing/src/imp/mod.rs
+++ b/keyset-drawing/src/imp/mod.rs
@@ -4,6 +4,7 @@ mod legend;
 use std::collections::HashSet;
 
 use ::key::Key;
+use ::key::Shape as KeyShape;
 use color::Color;
 use geom::{BezPath, Point, Shape, Vec2};
 
@@ -33,22 +34,14 @@ pub struct KeyDrawing {
 
 impl KeyDrawing {
     pub fn new(key: &Key, options: &Options) -> Self {
-        let show_key = options.show_keys && !matches!(key.typ, ::key::Type::None);
+        let show_key = options.show_keys && !matches!(key.shape, KeyShape::None(..));
 
         let bottom = show_key.then(|| key::bottom(key, options));
         let top = show_key.then(|| key::top(key, options));
         let step = show_key.then(|| key::step(key, options)).flatten();
         let homing = show_key.then(|| key::homing(key, options)).flatten();
 
-        let top_rect = match key.shape {
-            ::key::Shape::Normal(size) => options.profile.top_with_size(size).rect(),
-            ::key::Shape::SteppedCaps => options.profile.top_with_size((1.25, 1.)).rect(),
-            ::key::Shape::IsoHorizontal => options.profile.top_with_size((1.5, 1.)).rect(),
-            ::key::Shape::IsoVertical => {
-                let rect = options.profile.top_with_size((1.25, 2.)).rect();
-                rect.with_origin(rect.origin() + (250., 0.))
-            }
-        };
+        let top_rect = options.profile.top_with_rect(key.shape.inner_rect()).rect();
 
         let margin = options.show_margin.then(|| {
             // TODO get unique margins, not size_idx's. Currently impossible because Insets: !Hash
diff --git a/keyset-drawing/src/lib.rs b/keyset-drawing/src/lib.rs
index 24d8a75..7840d35 100644
--- a/keyset-drawing/src/lib.rs
+++ b/keyset-drawing/src/lib.rs
@@ -41,7 +41,7 @@ impl Drawing {
     pub fn new(keys: &[Key], options: &Options) -> Self {
         let bounds = keys
             .iter()
-            .map(|k| k.shape.bounds().with_origin(k.position))
+            .map(|k| k.shape.outer_rect().with_origin(k.position))
             .fold(
                 Rect::from_origin_size(Point::ORIGIN, Size::new(1., 1.)),
                 |rect, key| rect.union(key),
diff --git a/keyset-drawing/src/pdf.rs b/keyset-drawing/src/pdf.rs
index e55467b..0575c2b 100644
--- a/keyset-drawing/src/pdf.rs
+++ b/keyset-drawing/src/pdf.rs
@@ -171,7 +171,7 @@ mod tests {
     use crate::Options;
 
     #[test]
-    fn test_to_svg() {
+    fn test_to_pdf() {
         let options = Options {
             show_margin: true, // to give us an unfilled path
             ..Default::default()
diff --git a/keyset-key/src/kle/error.rs b/keyset-key/src/kle/error.rs
index 15da136..ca7face 100644
--- a/keyset-key/src/kle/error.rs
+++ b/keyset-key/src/kle/error.rs
@@ -1,14 +1,23 @@
+/// An error parsing a KLE layout
 #[derive(Debug)]
 pub enum Error {
+    /// An error in parsing the KLE JSON file
+    JsonParseError(serde_json::Error),
+    /// A key size not supported by `keyset`
     UnsupportedKeySize {
+        /// The key's `w` value
         w: f64,
+        /// The key's `h` value
         h: f64,
+        /// The key's `x2` value
         x2: f64,
+        /// The key's `y2` value
         y2: f64,
+        /// The key's `w2` value
         w2: f64,
+        /// The key's `h2` value
         h2: f64,
     },
-    JsonParseError(serde_json::Error),
 }
 
 impl std::fmt::Display for Error {
@@ -47,6 +56,7 @@ impl From<serde_json::Error> for Error {
     }
 }
 
+/// A [`std::result::Result`] with [`Error`] as it's error type
 pub type Result<T> = std::result::Result<T, Error>;
 
 #[cfg(test)]
diff --git a/keyset-key/src/kle/mod.rs b/keyset-key/src/kle/mod.rs
index 53e7c91..55308ce 100644
--- a/keyset-key/src/kle/mod.rs
+++ b/keyset-key/src/kle/mod.rs
@@ -1,12 +1,18 @@
+//! Load KLE layouts from JSON files
+
 mod error;
 
 use geom::{Point, Size};
 use kle_serial as kle;
 
-use crate::{Homing, Key, Legend, Shape, Type};
+use crate::{Homing, Key, Legend, Shape};
 pub use error::{Error, Result};
 
 fn shape_from_kle(key: &kle::Key) -> Result<Shape> {
+    const STEP_CAPS: [f64; 6] = [1.25, 1.0, 0.0, 0.0, 1.75, 1.0];
+    const ISO_VERT: [f64; 6] = [1.25, 2.0, -0.25, 0.0, 1.5, 1.0];
+    const ISO_HORIZ: [f64; 6] = [1.5, 1.0, 0.25, 0.0, 1.25, 2.0];
+
     fn is_close<const N: usize>(a: &[f64; N], b: &[f64; N]) -> bool {
         a.iter().zip(b).all(|(a, b)| (b - a).abs() < 1e-2)
     }
@@ -21,14 +27,31 @@ fn shape_from_kle(key: &kle::Key) -> Result<Shape> {
         ..
     } = key;
 
-    if is_close(&[w, h, x2, y2, w2, h2], &[1.25, 1., 0., 0., 1.75, 1.]) {
+    let is_normal = is_close(&[x2, y2, w2, h2], &[0.0, 0.0, w, h]);
+    let is_1u = is_normal && is_close(&[w, h], &[1.0, 1.0]);
+
+    let dims = [w, h, x2, y2, w2, h2];
+
+    if is_1u && (key.profile.contains("scoop") || key.profile.contains("dish")) {
+        Ok(Shape::Homing(Some(Homing::Scoop)))
+    } else if is_1u && key.profile.contains("bar") {
+        Ok(Shape::Homing(Some(Homing::Bar)))
+    } else if is_1u && (key.profile.contains("bump") || key.profile.contains("dot")) {
+        Ok(Shape::Homing(Some(Homing::Bump)))
+    } else if is_normal && key.profile.contains("space") {
+        Ok(Shape::Space(Size::new(w, h)))
+    } else if is_1u && key.homing {
+        Ok(Shape::Homing(None))
+    } else if key.decal {
+        Ok(Shape::None(Size::new(w, h)))
+    } else if is_normal {
+        Ok(Shape::Normal(Size::new(w, h)))
+    } else if is_close(&dims, &STEP_CAPS) {
         Ok(Shape::SteppedCaps)
-    } else if is_close(&[w, h, x2, y2, w2, h2], &[1.25, 2., -0.25, 0., 1.5, 1.]) {
+    } else if is_close(&dims, &ISO_VERT) {
         Ok(Shape::IsoVertical)
-    } else if is_close(&[w, h, x2, y2, w2, h2], &[1.5, 1., 0.25, 0., 1.25, 2.]) {
+    } else if is_close(&dims, &ISO_HORIZ) {
         Ok(Shape::IsoHorizontal)
-    } else if is_close(&[x2, y2, w2, h2], &[0., 0., w, h]) {
-        Ok(Shape::Normal(Size::new(w, h)))
     } else {
         // TODO support all key shapes/sizes
         Err(Error::UnsupportedKeySize {
@@ -42,29 +65,6 @@ fn shape_from_kle(key: &kle::Key) -> Result<Shape> {
     }
 }
 
-fn type_from_kle(key: &kle::Key) -> Type {
-    const SCOOP_KW: [&str; 2] = ["scoop", "dish"];
-    const BAR_KW: [&str; 2] = ["bar", "line"];
-    const BUMP_KW: [&str; 4] = ["bump", "dot", "nub", "nipple"];
-
-    // TODO support ghosted keys?
-    if SCOOP_KW.iter().any(|kw| key.profile.contains(kw)) {
-        Type::Homing(Some(Homing::Scoop))
-    } else if BAR_KW.iter().any(|kw| key.profile.contains(kw)) {
-        Type::Homing(Some(Homing::Bar))
-    } else if BUMP_KW.iter().any(|kw| key.profile.contains(kw)) {
-        Type::Homing(Some(Homing::Bump))
-    } else if key.profile.contains("space") {
-        Type::Space
-    } else if key.homing {
-        Type::Homing(None)
-    } else if key.decal {
-        Type::None
-    } else {
-        Type::Normal
-    }
-}
-
 impl From<kle::Legend> for Legend {
     fn from(legend: kle::Legend) -> Self {
         let kle::Legend { text, size, color } = legend;
@@ -82,7 +82,6 @@ impl TryFrom<kle::Key> for Key {
     fn try_from(mut key: kle::Key) -> Result<Self> {
         let position = Point::new(key.x + key.x2.min(0.), key.y + key.y2.min(0.));
         let shape = shape_from_kle(&key)?;
-        let typ = type_from_kle(&key);
         let color = key.color.rgb().into();
         let legends = {
             let mut arr = <[Option<kle::Legend>; 9]>::default();
@@ -93,13 +92,17 @@ impl TryFrom<kle::Key> for Key {
         Ok(Self {
             position,
             shape,
-            typ,
             color,
             legends,
         })
     }
 }
 
+/// Loads a KLE layout from a JSON string into a [`Vec<Key>`]
+///
+/// # Errors
+///
+/// If an
 pub fn from_json(json: &str) -> Result<Vec<Key>> {
     let key_iter: kle::KeyIterator = serde_json::from_str(json)?;
     key_iter.map(Key::try_from).collect()
@@ -114,6 +117,37 @@ mod tests {
 
     #[test]
     fn key_shape_from_kle() {
+        let default_key = shape_from_kle(&kle::Key::default()).unwrap();
+        let decal = shape_from_kle(&kle::Key {
+            decal: true,
+            ..Default::default()
+        })
+        .unwrap();
+        let space = shape_from_kle(&kle::Key {
+            profile: "space".into(),
+            ..Default::default()
+        })
+        .unwrap();
+        let homing_default = shape_from_kle(&kle::Key {
+            homing: true,
+            ..Default::default()
+        })
+        .unwrap();
+        let homing_scoop = shape_from_kle(&kle::Key {
+            profile: "scoop".into(),
+            ..Default::default()
+        })
+        .unwrap();
+        let homing_bar = shape_from_kle(&kle::Key {
+            profile: "bar".into(),
+            ..Default::default()
+        })
+        .unwrap();
+        let homing_bump = shape_from_kle(&kle::Key {
+            profile: "bump".into(),
+            ..Default::default()
+        })
+        .unwrap();
         let regular_key = shape_from_kle(&kle::Key {
             width: 2.25,
             height: 1.,
@@ -155,7 +189,14 @@ mod tests {
         })
         .unwrap();
 
-        assert_matches!(regular_key, Shape::Normal(size) if size == Size::new(2.25, 1.));
+        assert_matches!(default_key, Shape::Normal(size) if size == Size::new(1.0, 1.0));
+        assert_matches!(regular_key, Shape::Normal(size) if size == Size::new(2.25, 1.0));
+        assert_matches!(decal, Shape::None(size) if size == Size::new(1.0, 1.0));
+        assert_matches!(space, Shape::Space(size) if size == Size::new(1.0, 1.0));
+        assert_matches!(homing_default, Shape::Homing(None));
+        assert_matches!(homing_scoop, Shape::Homing(Some(Homing::Scoop)));
+        assert_matches!(homing_bar, Shape::Homing(Some(Homing::Bar)));
+        assert_matches!(homing_bump, Shape::Homing(Some(Homing::Bump)));
         assert_matches!(iso_horiz, Shape::IsoHorizontal);
         assert_matches!(iso_vert, Shape::IsoVertical);
         assert_matches!(step_caps, Shape::SteppedCaps);
@@ -184,45 +225,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn key_type_from_kle() {
-        let regular_key = type_from_kle(&kle::Key {
-            ..Default::default()
-        });
-        let decal = type_from_kle(&kle::Key {
-            decal: true,
-            ..Default::default()
-        });
-        let space = type_from_kle(&kle::Key {
-            profile: "space".into(),
-            ..Default::default()
-        });
-        let homing_default = type_from_kle(&kle::Key {
-            homing: true,
-            ..Default::default()
-        });
-        let homing_scoop = type_from_kle(&kle::Key {
-            profile: "scoop".into(),
-            ..Default::default()
-        });
-        let homing_bar = type_from_kle(&kle::Key {
-            profile: "bar".into(),
-            ..Default::default()
-        });
-        let homing_bump = type_from_kle(&kle::Key {
-            profile: "bump".into(),
-            ..Default::default()
-        });
-
-        assert_matches!(regular_key, Type::Normal);
-        assert_matches!(decal, Type::None);
-        assert_matches!(space, Type::Space);
-        assert_matches!(homing_default, Type::Homing(None));
-        assert_matches!(homing_scoop, Type::Homing(Some(Homing::Scoop)));
-        assert_matches!(homing_bar, Type::Homing(Some(Homing::Bar)));
-        assert_matches!(homing_bump, Type::Homing(Some(Homing::Bump)));
-    }
-
     #[test]
     fn kle_from_json() {
         let result1: Vec<_> = from_json(&unindent(
diff --git a/keyset-key/src/legend.rs b/keyset-key/src/legend.rs
index a942fce..b920c26 100644
--- a/keyset-key/src/legend.rs
+++ b/keyset-key/src/legend.rs
@@ -2,14 +2,19 @@ use std::ops::{Index, IndexMut};
 
 use color::Color;
 
+/// A single legend
 #[derive(Debug, Clone, PartialEq)]
 pub struct Legend {
+    /// The legend text
     pub text: String,
+    /// The legend size
     pub size_idx: usize,
+    /// The legend colour
     pub color: Color,
 }
 
 impl Legend {
+    /// Create a new [`Legend`]
     pub fn new(text: impl Into<String>, size_idx: usize, color: Color) -> Self {
         Self {
             text: text.into(),
@@ -19,11 +24,12 @@ impl Legend {
     }
 }
 
+/// A set of legends for a key
 #[derive(Debug, Clone, Default)]
 pub struct Legends([Option<Legend>; 9]);
 
 impl Legends {
-    // Example non-blank key used in some of our tests
+    /// An example non-blank set of legends
     #[must_use]
     pub fn example() -> Self {
         Self([
@@ -39,18 +45,21 @@ impl Legends {
         ])
     }
 
+    /// Creates an iterator in a left-to-right, top-to-bottom order
     pub fn iter(&self) -> std::slice::Iter<Option<Legend>> {
         self.0.iter()
     }
 }
 
 impl From<[Option<Legend>; 9]> for Legends {
+    /// Converts from an array in left-to-right, top-to-bottom order
     fn from(value: [Option<Legend>; 9]) -> Self {
         Self(value)
     }
 }
 
 impl From<[[Option<Legend>; 3]; 3]> for Legends {
+    /// Converts from an array of arrays in row-major order
     fn from(mut value: [[Option<Legend>; 3]; 3]) -> Self {
         let mut arr = <[Option<Legend>; 9]>::default();
         arr[0..3].swap_with_slice(&mut value[0]);
@@ -64,6 +73,7 @@ impl IntoIterator for Legends {
     type Item = Option<Legend>;
     type IntoIter = <[Option<Legend>; 9] as IntoIterator>::IntoIter;
 
+    /// Creates an iterator in a left-to-right, top-to-bottom order
     fn into_iter(self) -> Self::IntoIter {
         self.0.into_iter()
     }
@@ -72,12 +82,14 @@ impl IntoIterator for Legends {
 impl Index<usize> for Legends {
     type Output = Option<Legend>;
 
+    /// Indexes the legends arranged in left-to-right, top-to-bottom order
     fn index(&self, index: usize) -> &Self::Output {
         self.0.index(index)
     }
 }
 
 impl IndexMut<usize> for Legends {
+    /// Mutably indexes the legends arranged in left-to-right, top-to-bottom order
     fn index_mut(&mut self, index: usize) -> &mut Self::Output {
         self.0.index_mut(index)
     }
@@ -86,12 +98,14 @@ impl IndexMut<usize> for Legends {
 impl Index<(usize, usize)> for Legends {
     type Output = Option<Legend>;
 
+    /// Indexes the legends using a `(column, row)` tuple
     fn index(&self, (column, row): (usize, usize)) -> &Self::Output {
         self.0.index(row * 3 + column)
     }
 }
 
 impl IndexMut<(usize, usize)> for Legends {
+    /// Mutably indexes the legends using a `(column, row)` tuple
     fn index_mut(&mut self, (column, row): (usize, usize)) -> &mut Self::Output {
         self.0.index_mut(row * 3 + column)
     }
diff --git a/keyset-key/src/lib.rs b/keyset-key/src/lib.rs
index eb09bb6..eada9a7 100644
--- a/keyset-key/src/lib.rs
+++ b/keyset-key/src/lib.rs
@@ -1,3 +1,8 @@
+//! This crate contains the key and legend types used for describing layouts used internally by
+//! [keyset]. It also contains utility functions for loading KLE layouts
+//!
+//! [keyset]: https://crates.io/crates/keyset
+
 #![warn(
     missing_docs,
     clippy::all,
@@ -10,7 +15,6 @@
     clippy::cargo,
     clippy::nursery
 )]
-#![allow(missing_docs, clippy::missing_errors_doc)] // TODO
 
 mod legend;
 
@@ -23,75 +27,97 @@ use geom::{Point, Rect, Size};
 
 use color::Color;
 
+/// The type of homing used on a homing key
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Homing {
+    /// A scooped homing key, also known as a dished homing key
     Scoop,
+    /// A key with a homing bar, sometimes called a line
     Bar,
+    /// A key with a homing bump, also known as a nub, dot, or nipple
     Bump,
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum Type {
-    None,   // a.k.a. decal in KLE lingo
-    Normal, // Just a regular ol' key
-    Homing(Option<Homing>),
-    Space,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq)]
+/// The shape of a key
+#[derive(Debug, Clone, Copy)]
 pub enum Shape {
+    /// Not a *key* per se, but only a legend. This is usually used for labels and is the same as a
+    /// decal in KLE
+    None(Size),
+    /// A regular key of the given size
     Normal(Size),
+    /// A spacebar of the given size
+    Space(Size),
+    /// A homing key with the given homing type. If the homing type is [`None`] the profile's
+    /// default homing type is assumed to be used
+    Homing(Option<Homing>),
+    /// A stepped caps lock key, i.e. a 1.25u key with additional 0.5u step on the right
     SteppedCaps,
+    /// A vertically-aligned ISO enter, i.e. an ISO enter where legends are aligned within the
+    /// vertical 1.25u &times; 2.0u section of the key
     IsoVertical,
+    /// A horizontally-aligned ISO enter, i.e. an ISO enter where legends are aligned within the
+    /// horizontal 1.5u top section of the key
     IsoHorizontal,
 }
 
-impl From<Size> for Shape {
-    fn from(value: Size) -> Self {
-        Self::Normal(value)
-    }
-}
-
 impl Shape {
+    /// The outer bounding rectangle of the key shape, i.e. the bounding box of the key shape. The
+    /// inner and outer bounds are the same for regular-shaped keys, but are different for stepped
+    /// keys, L-shaped keys, etc.
     #[must_use]
-    pub fn bounds(self) -> Rect {
+    pub fn outer_rect(self) -> Rect {
         match self {
-            Self::Normal(size) => Rect::from_origin_size((0.0, 0.0), size),
-            Self::IsoHorizontal | Self::IsoVertical => {
-                Rect::from_origin_size((0.0, 0.0), (1.5, 2.0))
+            Self::None(size) | Self::Normal(size) | Self::Space(size) => {
+                Rect::from_origin_size(Point::ORIGIN, size)
+            }
+            Self::Homing(..) => Rect::from_origin_size(Point::ORIGIN, (1.0, 1.0)),
+            Self::SteppedCaps => Rect::from_origin_size(Point::ORIGIN, (1.75, 1.0)),
+            Self::IsoVertical | Self::IsoHorizontal => {
+                Rect::from_origin_size(Point::ORIGIN, (1.5, 2.0))
             }
-            Self::SteppedCaps => Rect::from_origin_size((0.0, 0.0), (1.75, 1.0)),
         }
     }
 
+    /// The inner bounding rectangle of the key shape, i.e. the bounds for the part of the key
+    /// containing the legend. The inner and outer bounds are the same for regular-shaped keys, but
+    /// are different for stepped keys, L-shaped keys, etc.
     #[must_use]
-    pub fn margin(self) -> Rect {
+    pub fn inner_rect(self) -> Rect {
         match self {
-            Self::Normal(size) => Rect::from_origin_size((0.0, 0.0), size),
-            Self::SteppedCaps => Rect::from_origin_size((0.0, 0.0), (1.25, 1.0)),
+            Self::None(size) | Self::Normal(size) | Self::Space(size) => {
+                Rect::from_origin_size(Point::ORIGIN, size)
+            }
+            Self::Homing(..) => Rect::from_origin_size(Point::ORIGIN, (1.0, 1.0)),
+            Self::SteppedCaps => Rect::from_origin_size(Point::ORIGIN, (1.25, 1.0)),
             Self::IsoVertical => Rect::from_origin_size((0.25, 0.0), (1.25, 2.0)),
-            Self::IsoHorizontal => Rect::from_origin_size((0.0, 0.0), (1.5, 1.0)),
+            Self::IsoHorizontal => Rect::from_origin_size(Point::ORIGIN, (1.5, 1.0)),
         }
     }
 }
 
+/// A key
 #[derive(Debug, Clone)]
 #[non_exhaustive]
 pub struct Key {
+    /// The position of the key
     pub position: Point,
+    /// The key's shape
     pub shape: Shape,
-    pub typ: Type,
+    /// The key's colour
     pub color: Color,
+    /// The key's legends
     pub legends: Legends,
 }
 
 impl Key {
+    /// A new blank key
     #[must_use]
     pub fn new() -> Self {
         Self::default()
     }
 
-    // Example non-blank key used in some of our tests
+    /// An example non-blank key
     #[must_use]
     pub fn example() -> Self {
         Self {
@@ -106,7 +132,6 @@ impl Default for Key {
         Self {
             position: Point::ORIGIN,
             shape: Shape::Normal(Size::new(1., 1.)),
-            typ: Type::Normal,
             color: Color::new(0.8, 0.8, 0.8),
             legends: Legends::default(),
         }
@@ -120,31 +145,43 @@ pub mod tests {
     use super::*;
 
     #[test]
-    fn shape_bounds() {
+    fn shape_outer_size() {
         assert_eq!(
-            Shape::Normal(Size::new(2.25, 1.)).bounds(),
+            Shape::Normal(Size::new(2.25, 1.)).outer_rect(),
             Rect::new(0.0, 0.0, 2.25, 1.)
         );
-        assert_eq!(Shape::IsoVertical.bounds(), Rect::new(0.0, 0.0, 1.5, 2.0));
-        assert_eq!(Shape::IsoHorizontal.bounds(), Rect::new(0.0, 0.0, 1.5, 2.0));
-        assert_eq!(Shape::SteppedCaps.bounds(), Rect::new(0.0, 0.0, 1.75, 1.0));
+        assert_eq!(
+            Shape::IsoVertical.outer_rect(),
+            Rect::new(0.0, 0.0, 1.5, 2.0)
+        );
+        assert_eq!(
+            Shape::IsoHorizontal.outer_rect(),
+            Rect::new(0.0, 0.0, 1.5, 2.0)
+        );
+        assert_eq!(
+            Shape::SteppedCaps.outer_rect(),
+            Rect::new(0.0, 0.0, 1.75, 1.0)
+        );
     }
 
     #[test]
-    fn shape_margin() {
+    fn shape_inner_size() {
         assert_eq!(
-            Shape::Normal(Size::new(2.25, 1.)).margin(),
+            Shape::Normal(Size::new(2.25, 1.)).inner_rect(),
             Rect::new(0.0, 0.0, 2.25, 1.)
         );
-        assert_eq!(Shape::IsoVertical.margin(), Rect::new(0.25, 0.0, 1.5, 2.0));
-        assert_eq!(Shape::IsoHorizontal.margin(), Rect::new(0.0, 0.0, 1.5, 1.0));
-        assert_eq!(Shape::SteppedCaps.margin(), Rect::new(0.0, 0.0, 1.25, 1.0));
-    }
-
-    #[test]
-    fn shape_from() {
-        let shape = Shape::from(Size::new(1.75, 1.));
-        assert_matches!(shape, Shape::Normal(x) if x == Size::new(1.75, 1.));
+        assert_eq!(
+            Shape::IsoVertical.inner_rect(),
+            Rect::new(0.25, 0.0, 1.5, 2.0)
+        );
+        assert_eq!(
+            Shape::IsoHorizontal.inner_rect(),
+            Rect::new(0.0, 0.0, 1.5, 1.0)
+        );
+        assert_eq!(
+            Shape::SteppedCaps.inner_rect(),
+            Rect::new(0.0, 0.0, 1.25, 1.0)
+        );
     }
 
     #[test]
@@ -153,7 +190,6 @@ pub mod tests {
 
         assert_eq!(key.position, Point::new(0., 0.));
         assert_matches!(key.shape, Shape::Normal(size) if size == Size::new(1., 1.));
-        assert_matches!(key.typ, Type::Normal);
         assert_eq!(key.color, Color::new(0.8, 0.8, 0.8));
         for legend in key.legends {
             assert!(legend.is_none());
@@ -167,7 +203,6 @@ pub mod tests {
 
         assert_eq!(key.position, Point::new(0., 0.));
         assert_matches!(key.shape, Shape::Normal(size) if size == Size::new(1., 1.));
-        assert_matches!(key.typ, Type::Normal);
         assert_eq!(key.color, Color::new(0.8, 0.8, 0.8));
         for (legend, is_some) in key.legends.into_iter().zip(legend_is_some) {
             assert_eq!(legend.is_some(), is_some);
diff --git a/keyset-profile/src/lib.rs b/keyset-profile/src/lib.rs
index 5e84f5c..fda212c 100644
--- a/keyset-profile/src/lib.rs
+++ b/keyset-profile/src/lib.rs
@@ -293,6 +293,12 @@ impl Profile {
         top_rect.with_size(top_rect.size() + 1e3 * (size.into() - Size::new(1., 1.)))
     }
 
+    pub fn top_with_rect(&self, rect: impl Into<Rect>) -> RoundRect {
+        let rect = rect.into();
+        let result = self.top_with_size(rect.size());
+        result.with_origin(result.origin() + 1e3 * rect.origin().to_vec2())
+    }
+
     pub fn bottom_with_size(&self, size: impl Into<Size>) -> RoundRect {
         let bottom_rect = self.bottom.round_rect();
         bottom_rect.with_size(bottom_rect.size() + 1e3 * (size.into() - Size::new(1., 1.)))