Squashed 'third_party/allwpilib/' changes from e473a00f97..e4b91005cf

e4b91005cf [examples] Update SwerveModule constructor doc (NFC) (#4042)
a260bfd83b [examples] Remove "this" keyword from SwerveModule (#4043)
18e262a100 [examples] Fix multiple doc typos in SwerveControllerCommand example (NFC) (#4044)
4bd1f526ab [wpilibc] Prevent StopMotor from terminating robot during MotorSafety check (#4038)
27847d7eb2 [sim] Expose GUI control functions via HAL_RegisterExtension (#4034)
b2a8d3f0f3 [wpilibc] Add mechanism to reset MotorSafety list (#4037)
49adac9564 [wpilib] Check for signedness in ArcadeDriveIK() (#4028)
a19d1133b1 [wpiutil] libuv: Fix sign compare warnings in gcc 11.2 (#4031)
dde91717e4 [build] cmake: Add ability to customize target warnings (#4032)
e9050afd67 [sim] Update sim match time to match real robot (#4024)
165d2837cf [wpilib] Preferences: Set Persistent in Init methods (#4025)
ac7549edca [glass] Fix snprintf truncation warning (#4029)
4d96bc72e0 [wpilibj] Fix typos in error messages for non-null assertions (#4014)
3411eee20f [hal] Replace hardcoded sim array sizes with constants (#4015)
74de97eeca [wpilibc] Add mechanism to reset various global structures (#4007)
4e3cc25012 [examples] Fix periodic function rate comment (NFC) (#4013)
90c1db393e [sim] Add exported functions to control the sim GUI (#3995)
2f43274aa4 [wpilibj] MechanismRoot2d: Add flush to setPosition (#4011)
aeca09db09 [glass] Support remapping of Enter key (#3994)
c107f22c67 [sim] Sim GUI: don't force-show Timing and Other Devices (#4001)
68fe51e8da [wpigui] Update PFD to latest, fix kdialog multiselect (#4005)
8d08d67cf1 [wpigui] PFD: Add console warning if file chooser unavailable (#4003)
4f1782f66e [wpilibc] Only call HAL_Report when initializing SmartDashboard (#4006)
3f77725cd3 Remove uses of iostream (#4004)
5635f33a32 [glass] Increase plot depth to 20K points (#3993)
bca4b7111b [glass] Fix PlotSeries::SetSource() (#3991)
6a6366b0d6 [commands] Add until() as alias for withInterrupt() (#3981)
16bf2c70c5 [wpilib] Fix joystick out of range error messages (#3988)
4b3edb742c [wpilib] Fix ADIS16448 IMU default constructor not working in Java (#3989)
fcf23fc9e9 [hal] Fix potential gamedata out of bounds read (#3983)
af5ef510c5 [wpilibc] Fix REV PH pulse duration units (#3982)
05401e2b81 [wpilib] Write REV PH firmware version to roboRIO to display on driver station (#3977)
9fde0110b6 Update to 2022 v4.0 image (#3944)
b03f8ddb2e [examples] fix incorrect variable in Arm Simulation Pref (#3980)
a26df2a022 [examples] Update ArmSimulation example to use Preferences (#3976)
d68d6674e8 [examples] Armbot: rename kCos to kG (#3975)
a8f0f6bb90 [wpilibj] Fix ADIS16448 getRate to return rate instead of angle (#3974)
dd9c92d5bf [build] Remove debug info from examples (#3971)
84df14dd70 [rtns] Fix icons (#3972)
560094ad92 [examples] Correct Mecanum example axes (#3955)
7ea1be9c01 [wpilibc] Fix typo in hardware version for REV PDH (#3969)
700f13bffd [wpilibj] Make methods public for Java REV PDH (#3970)
b6aa7c1aa9 [wpilibj] Make methods public for Java REVPH (#3968)
eb4d183e48 [wpimath] Fix clang-tidy bugprone-integer-division warning (#3966)
77e4e81e1e [wpilib] Add Field widget to BuiltInWidgets in shuffleboard (#3961)
88f5cb6eb0 [build] Publish PDBs with C++ tools (#3960)
efae552f3e [wpimath] Remove DifferentialDriveKinematics include from odometry (#3958)
46b277421a [glass] Update Speed Controller Type name for 2022 WPILib (#3952)
42908126b9 [wpilib] Add DCMotorSim (#3910)
a467392cbd [wpiutil] StackTrace: Add ability to override default implementation (#3951)
78d0bcf49d [templates] Add SimulationInit()/SimulationPeriodic() to robot templates (#3943)
02a0ced9b0 [wpilib] MecanumDrive: update docs for axis to match implementation (NFC) (#3942)
4ccfe1c9f2 [wpilib] Added docs clarification on units for drive class WheelSpeeds (NFC) (#3939)
830c0c5c2f [wpilib] MechanismLigament2d: Add getters for color and line weight (#3947)
5548a37465 [wpilib] PowerDistribution: Add module type getter (#3948)
2f9a600de2 [hal] Fix PCM one shot (#3949)
559db11a20 [myRobot] Skip deploying debug libraries for myRobot deploys (#3950)
76c78e295b [examples] Reorder SwerveModules in SwerveControllerCommand example odometry update (#3934)
debbd5ff4b [wpilib] Improve PowerDistribution docs (NFC) (#3925)
841174f302 [commands] Change command vendordep JSON version number to 1.0.0 (#3938)
8c55844f91 [wpilib] Remove comment about Mecanum right side inverted (NFC) (#3929)
0b990bf0f5 [hal] Fix PCM sticky faults clear function crashing (#3932)
104d7e2abc [hal] Don't throw exceptions in PCM JNI (#3933)
5ba69e1af1 [examples] Updated type in Java SwerveModule (#3928)
f3a0b5c7d7 [wpimath] Fix Java SimpleMotorFeedforward Docs (NFC) (#3926)
7f4265facc [wpimath] Add LinearFilter::FiniteDifference() (#3900)
63d1fb3bed [wpiutil] Modify fmt to not throw on write failure (#3919)
36af6d25a5 [wpimath] Fix input vector in pose estimator docs (NFC) (#3923)
8f387f7255 [wpilibj] Switch ControlWord mutex to actual reentrant mutex (#3922)
792e735e08 [wpimath] Move TrajectoryGenerator::SetErrorHandler definition to .cpp (#3920)
3b76de83eb [commands] Fix ProfiledPIDCommand use-after-free (#3904)
ad9f738cfa [fieldimages] Fix maven publishing (#3897)
49455199e5 [examples] Use left/rightGroup.Get() for simulator inputs to fix inversions (#3908)
64426502ea [wpimath] Fix arm -> flywheel typo (NFC) (#3911)
8cc112d196 [wpiutil] Fix wpi::array for move-only types (#3917)
e78cd49861 [build] Upgrade Java formatter plugins (#3894)
cfb4f756d6 [build] Upgrade to shadow 7.1.2 (#3893)
ba0908216c [wpimath] Fix crash in KF latency compensator (#3888)
a3a0334fad [build] cmake: Move fieldImages to WITH_GUI (#3885)
cf7460c3a8 [fieldImages] Add 2022 field (#3883)
db0fbb6448 [wpimath] Fix LQR matrix constructor overload for Q, R, and N (#3884)
8ac45f20bb [commands] Update Command documentation (NFC) (#3881)
b3707cca0b [wpiutil] Upgrade to fmt 8.1.1 (#3879)
a69ee3ece9 [wpimath] Const-qualify Twist2d scalar multiply (#3882)
750d9a30c9 [examples] Fix Eigen out of range error when running example (#3877)
41c5b2b5ac [rtns] Add cmake build (#3866)
6cf3f9b28e [build] Upgrade to Gradle 7.3.3 (#3878)
269cf03472 [examples] Add communication examples (e.g. arduino) (#2500)
5ccfc4adbd [oldcommands] Deprecate PIDWrappers, since they use deprecated interfaces (#3868)
b6f44f98be [hal] Add warning about onboard I2C (#3871)
0dca57e9ec [templates] romieducational: Invert drivetrain and disable motor safety (#3869)
22c4da152e [wpilib] Add GetRate() to ADIS classes (#3864)
05d66f862d [templates] Change the template ordering to put command based first (#3863)
b09f5b2cf2 [wpilibc] Add virtual dtor for LinearSystemSim (#3861)
a2510aaa0e [wpilib] Make ADIS IMU classes unit-safe (#3860)
947f589916 [wpilibc] Rename ADIS_16470_IMU.cpp to match class name (#3859)
bbd8980a20 [myRobot] Fix cameraserver library order (#3858)
831052f118 [wpilib] Add simulation support to ADIS classes (#3857)
c137569f91 [wpilib] Throw exception if the REV Pneumatic Hub firmware version is older than 22.0.0 (#3853)
dae61226fa Fix Maven Artifacts readme (#3856)
3ad4594a88 Update Maven artifacts readme for 2022 (#3855)
112acb9a62 [wpilibc] Move ADIS IMU constants to inside class (#3852)
ecee224e81 [wpilib] Allow SendableCameraWrappers to take arbitrary URLs (#3850)
a3645dea34 LICENSE: Bump year range to include 2022 (#3854)
7c09f44898 [wpilib] Use PSI for compressor config and sensor reading (#3847)
f401ea9aae [wpigui] Remove wpiutil linkage (#3851)
bf8517f1e6 [wpimath] TimeInterpolatableBufferTest: Fix lint warnings (#3849)
528087e308 [hal] Use enums with fixed underlying type in clang C (#3297)
1f59ff72f9 [wpilib] Add ADIS IMUs (#3777)
315be873c4 [wpimath] Add TimeInterpolatableBuffer (#2695)
b8d019cdb4 [wpilib] Rename NormalizeWheelSpeeds to DesaturateWheelSpeeds (#3791)
102f23bbdb [wpilibj] DriverStation: Set thread interrupted state (#3846)
b85c24a79c [wpilib] Add warning about onboard I2C (#3842)
eee29daaf9 [newCommands] Trigger: Allow override of debounce type (#3845)
aa9dfabde2 [wpimath] Move debouncer to filters (#3838)
5999a26fba [wpiutil] Add GetSystemTime() (#3840)
1e82595ffb [examples] Fix arcade inversions (#3841)
e373fa476b [wpiutil] Add disableMockTime to JNI (#3839)
dceb5364f4 [examples] Ensure right side motors are inverted (#3836)
baacbc8e24 [wpilib] Tachometer: Add function to return RPS (#3833)
84b15f0883 [templates] Add Java Romi Educational template (#3837)
c0da9d2d35 [examples] Invert Right Motor in Romi Java examples (#3828)
0fe0be2733 [build] Change project year to intellisense (#3835)
eafa947338 [wpimath] Make copies of trajectory constraint arguments (#3832)
9d13ae8d01 [wpilib] Add notes for Servo get that it only returns cmd (NFC) (#3820)
2a64e4bae5 [wpimath] Give drivetrain a more realistic width in TrajectoryJsonTest.java (#3822)
c3fd20db59 [wpilib] Fix trajectory sampling in DifferentialDriveSim test (#3821)
6f91f37cd0 [examples] Fix SwerveControllerCommand order of Module States (#3815)
5158730b81 [wpigui] Upgrade to imgui 1.86, GLFW 3.3.6 (#3817)
2ad2d2ca96 [wpiutil] MulticastServiceResolver: Fix C array returning functions (#3816)
b5fd29774f [wpilibj] Trigger: implement BooleanSupplier interface (#3811)
9f8f330e96 [wpilib] Fix Mecanum and SwerveControllerCommand when desired rotation passed (#3808)
1ad3b1b333 [hal] Don't copy byte to where null terminator goes (#3807)
dfc24425c3 [build] Fix gazebo gradle IDE warnings (#3806)
c02577bb51 [glass] Configure delay loading for windows camera server support (#3803)
c9e6a96a61 [wpilib] Document range of Servo angle (NFC) (#3796)
9778626f34 [wpilib, hal] Add support for getting faults and versions from power distribution (#3794)
34b2d0dae1 [wpilib, hal] High Level REV PH changes (#3792)
59a7528fd6 [cscore] Fix crash when usbcamera is deleted before message pump thread fully starts (#3804)
11d9859ef1 [build] Update plugins to remove log4j vulnerabilities (#3805)
e44ed752ad [glass] Fix CollapsingHeader in Encoder, PCM, and DeviceTree (#3797)
52b2dd5b89 [build] Bump native utils to remove log4j (#3802)
c46636f218 [wpilib] Improve new counter classes documentation (NFC) (#3801)
dc531462e1 [build] Update to gradle 7.3.2 (#3800)
92ba98621c [wpimath] Add helper variable templates for units type traits (#3790)
d41d051f1b [wpilibc] Fix Mecanum & Swerve ControllerCommand lambda capture (#3795)
c5ae0effac OtherVersions.md: Add one missing case of useLocal (#3788)
b3974c6ed3 [wpimath] Upgrade to Drake v0.37.0 (#3786)
589a00e379 [wpilibc] Start DriverStation thread from RobotBase (#3785)
8d9836ca02 [wpilib] Improve curvature drive documentation (NFC) (#3783)
8b5bf8632e [myRobot] Add wpimath and wpiutil JNI (#3784)
1846114491 [examples] Update references from characterization to SysId (NFC) (#3782)
2c461c794e [build] Update to gradle 7.3 (#3778)
109363daa4 [hal] Add remaining driver functions for REVPH (#3776)
41d26bee8d [hal] Refactor REV PDH (#3775)
7269a170fb Upgrade maven deps to latest versions and fix new linter errors (#3772)
441f2ed9b0 [build] actions: use fixed image versions instead latest (#3761)
15275433d4 [examples] Fix duplicate port allocations in C++ SwerveBot/SwerveDrivePoseEstimator/RomiReference (#3773)
1ac02d2f58 [examples] Fix drive Joystick axes in several examples (#3769)
8ee6257e92 [wpilib] DifferentialDrivetrainSim.KitbotMotor: Add NEO and Falcon 500 (#3762)
d81ef2bc5c [wpilib] Fix deadlocks in Mechanism2d et al. (#3770)
acb64dff97 [wpimath] Make RamseteController::Calculate() more concise (#3763)
3f6cf76a8c [hal] Refactor REV PH CAN frames (#3756)
3ef2dab465 [wpilib] DutyCycleEncoder: add setting of duty cycle range (#3759)
a5a56dd067 Readme: Add Visual Studio 2022 (#3760)
04957a6d30 [wpimath] Fix units of RamseteController's b and zeta (#3757)
5da54888f8 [glass] Upgrade imgui to 0.85, implot to HEAD, glfw to 3.3.5 (#3754)
6c93365b0f [wpiutil] MulticastService cleanup (#3750)
1c4a8bfb66 [cscore] Cleanup Windows USB camera impl (#3751)
d51a1d3b3d [rtns] Fix icon (#3749)
aced2e7da6 Add roboRIO Team Number Setter tool (#3744)
fa1ceca83a [wpilibj] Use DS cache for iterative robot control word cache (#3748)
0ea05d34e6 [build] Update to gradle 7.2 (#3746)
09db4f672b [build] Update to native utils 2022.6.1 (#3745)
4ba80a3a8c [wpigui] Don't recursively render frames in size callback (#3743)
ae208d2b17 [wpiutil] StringExtras: Add substr() (#3742)
6f51cb3b98 [wpiutil] MulticastResolver: make event manual reset, change to multiple read (#3736)
f6159ee1a2 [glass] Fix Drive widget handling of negative rotation (#3739)
7f401ae895 [build] Update NI libraries to 2022.2.3 (#3738)
0587b7043a [glass] Use JSON files for storage instead of imgui ini
0bbf51d566 [wpigui] Change maximized to bool
92c6eae6b0 [wpigui] PFD: Add explicit to constructors
141354cd79 [wpigui] Add hooks for custom load/save settings
f6e9fc7d71 [wpiutil] Handle multicast service collision on linux (#3734)
d8418be7d1 [glass, outlineviewer] Return 0 from WinMain (#3735)
82066946e5 [wpiutil] Add mDNS resolver and announcer (#3733)
4b1defc8d8 [wpilib] Remove automatic PD type from module type enum (#3732)
da90c1cd2c [wpilib] Add bang-bang controller (#3676)
3aa54fa027 [wpilib] Add new counter implementations (#2447)
b156db400d [hal, wpilib] Incorporate pneumatic control type into wpilibc/j (#3728)
9aba2b7583 [oldCommands] Add wrappers for WPILib objects to work with old PID Controller (#3710)
a9931223f0 [hal] Add REV PH faults (#3729)
aacf9442e4 [wpimath] Fix units typo in LinearSystemId source comment (#3730)
7db10ecf00 [wpilibc] Make SPI destructor virtual since SPI contains virtual functions (#3727)
a0a5b2aea5 [wpimath] Upgrade to EJML 0.41 (#3726)
eb835598a4 [hal] Add HAL functions for compressor config modes on REV PH (#3724)
f0ab6df5b6 [wpimath] Upgrade to Drake v0.36.0 (#3722)
075144faa3 [docs] Parse files without extensions with Doxygen (#3721)
32468a40cb [hal] Remove use of getDmaDescriptor from autospi (#3717)
38611e9dd7 [hal] Fix REVPH Analog Pressure channel selection (#3716)
4d78def31e [wpilib] Add DeadbandElimination forwarding to PWMMotorController (#3714)
3be0c1217a [wpilibcExamples] Make GearsBot use idiomatic C++ (#3711)
42d3a50aa2 [hal] Check error codes during serial port initialization (#3712)
52f1464029 Add project with field images and their json config files (#3668)
68ce62e2e9 [hal] Add autodetect for power modules (#3706)
3dd41c0d37 [wpilib] Don't print PD errors for LiveWindow reads (#3708)
7699a1f827 [hal] Fix sim not working with automatic PD type and default module (#3707)

Change-Id: I6b0b8fa8b2d2a24071240f624db9ec6d127f6648
git-subtree-dir: third_party/allwpilib
git-subtree-split: e4b91005cf69161f1cb3d934f6526232e6b9169e
Signed-off-by: Austin Schuh <austin.linux@gmail.com>
diff --git a/glass/src/app/native/cpp/camerasupport.cpp b/glass/src/app/native/cpp/camerasupport.cpp
new file mode 100644
index 0000000..eda3e6e
--- /dev/null
+++ b/glass/src/app/native/cpp/camerasupport.cpp
@@ -0,0 +1,52 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "camerasupport.h"
+
+#ifdef _WIN32
+#include "Windows.h"
+#include "delayimp.h"
+#pragma comment(lib, "delayimp.lib")
+static int CheckDelayException(int exception_value) {
+  if (exception_value ==
+          VcppException(ERROR_SEVERITY_ERROR, ERROR_MOD_NOT_FOUND) ||
+      exception_value ==
+          VcppException(ERROR_SEVERITY_ERROR, ERROR_PROC_NOT_FOUND)) {
+    // This example just executes the handler.
+    return EXCEPTION_EXECUTE_HANDLER;
+  }
+  // Don't attempt to handle other errors
+  return EXCEPTION_CONTINUE_SEARCH;
+}
+static bool TryDelayLoadAllImports(LPCSTR szDll) {
+  __try {
+    HRESULT hr = __HrLoadAllImportsForDll(szDll);
+    if (FAILED(hr)) {
+      return false;
+    }
+  } __except (CheckDelayException(GetExceptionCode())) {
+    return false;
+  }
+  return true;
+}
+namespace glass {
+bool HasCameraSupport() {
+  bool hasCameraSupport = false;
+  hasCameraSupport = TryDelayLoadAllImports("MF.dll");
+  if (hasCameraSupport) {
+    hasCameraSupport = TryDelayLoadAllImports("MFPlat.dll");
+  }
+  if (hasCameraSupport) {
+    hasCameraSupport = TryDelayLoadAllImports("MFReadWrite.dll");
+  }
+  return hasCameraSupport;
+}
+}  // namespace glass
+#else
+namespace glass {
+bool HasCameraSupport() {
+  return true;
+}
+}  // namespace glass
+#endif
diff --git a/glass/src/app/native/cpp/camerasupport.h b/glass/src/app/native/cpp/camerasupport.h
new file mode 100644
index 0000000..0be62c7
--- /dev/null
+++ b/glass/src/app/native/cpp/camerasupport.h
@@ -0,0 +1,9 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+namespace glass {
+bool HasCameraSupport();
+}  // namespace glass
diff --git a/glass/src/app/native/cpp/main.cpp b/glass/src/app/native/cpp/main.cpp
index 3e0adf1..b1569b8 100644
--- a/glass/src/app/native/cpp/main.cpp
+++ b/glass/src/app/native/cpp/main.cpp
@@ -11,7 +11,9 @@
 #include <wpigui.h>
 
 #include "glass/Context.h"
+#include "glass/MainMenuBar.h"
 #include "glass/Model.h"
+#include "glass/Storage.h"
 #include "glass/View.h"
 #include "glass/networktables/NetworkTables.h"
 #include "glass/networktables/NetworkTablesProvider.h"
@@ -39,9 +41,14 @@
 static std::unique_ptr<glass::NetworkTablesModel> gNetworkTablesModel;
 static std::unique_ptr<glass::NetworkTablesSettings> gNetworkTablesSettings;
 static glass::LogData gNetworkTablesLog;
-static glass::Window* gNetworkTablesWindow;
-static glass::Window* gNetworkTablesSettingsWindow;
-static glass::Window* gNetworkTablesLogWindow;
+static std::unique_ptr<glass::Window> gNetworkTablesWindow;
+static std::unique_ptr<glass::Window> gNetworkTablesSettingsWindow;
+static std::unique_ptr<glass::Window> gNetworkTablesLogWindow;
+
+static glass::MainMenuBar gMainMenu;
+static bool gAbout = false;
+static bool gSetEnterKey = false;
+static bool gKeyEdit = false;
 
 static void NtInitialize() {
   // update window title when connection status changes
@@ -84,48 +91,65 @@
     }
   });
 
-  gNetworkTablesLogWindow = gNtProvider->AddWindow(
-      "NetworkTables Log",
+  gNetworkTablesLogWindow = std::make_unique<glass::Window>(
+      glass::GetStorageRoot().GetChild("NetworkTables Log"),
+      "NetworkTables Log", glass::Window::kHide);
+  gNetworkTablesLogWindow->SetView(
       std::make_unique<glass::LogView>(&gNetworkTablesLog));
-  if (gNetworkTablesLogWindow) {
-    gNetworkTablesLogWindow->SetDefaultPos(250, 615);
-    gNetworkTablesLogWindow->SetDefaultSize(600, 130);
-    gNetworkTablesLogWindow->SetVisible(false);
-    gNetworkTablesLogWindow->DisableRenamePopup();
-  }
+  gNetworkTablesLogWindow->SetDefaultPos(250, 615);
+  gNetworkTablesLogWindow->SetDefaultSize(600, 130);
+  gNetworkTablesLogWindow->DisableRenamePopup();
+  gui::AddLateExecute([] { gNetworkTablesLogWindow->Display(); });
 
   // NetworkTables table window
   gNetworkTablesModel = std::make_unique<glass::NetworkTablesModel>();
   gui::AddEarlyExecute([] { gNetworkTablesModel->Update(); });
 
-  gNetworkTablesWindow = gNtProvider->AddWindow(
-      "NetworkTables",
+  gNetworkTablesWindow = std::make_unique<glass::Window>(
+      glass::GetStorageRoot().GetChild("NetworkTables View"), "NetworkTables");
+  gNetworkTablesWindow->SetView(
       std::make_unique<glass::NetworkTablesView>(gNetworkTablesModel.get()));
-  if (gNetworkTablesWindow) {
-    gNetworkTablesWindow->SetDefaultPos(250, 277);
-    gNetworkTablesWindow->SetDefaultSize(750, 185);
-    gNetworkTablesWindow->DisableRenamePopup();
-  }
+  gNetworkTablesWindow->SetDefaultPos(250, 277);
+  gNetworkTablesWindow->SetDefaultSize(750, 185);
+  gNetworkTablesWindow->DisableRenamePopup();
+  gui::AddLateExecute([] { gNetworkTablesWindow->Display(); });
 
   // NetworkTables settings window
-  gNetworkTablesSettings = std::make_unique<glass::NetworkTablesSettings>();
+  gNetworkTablesSettings = std::make_unique<glass::NetworkTablesSettings>(
+      glass::GetStorageRoot().GetChild("NetworkTables Settings"));
   gui::AddEarlyExecute([] { gNetworkTablesSettings->Update(); });
 
-  gNetworkTablesSettingsWindow = gNtProvider->AddWindow(
-      "NetworkTables Settings", [] { gNetworkTablesSettings->Display(); });
-  if (gNetworkTablesSettingsWindow) {
-    gNetworkTablesSettingsWindow->SetDefaultPos(30, 30);
-    gNetworkTablesSettingsWindow->SetFlags(ImGuiWindowFlags_AlwaysAutoResize);
-    gNetworkTablesSettingsWindow->DisableRenamePopup();
-  }
+  gNetworkTablesSettingsWindow = std::make_unique<glass::Window>(
+      glass::GetStorageRoot().GetChild("NetworkTables Settings"),
+      "NetworkTables Settings");
+  gNetworkTablesSettingsWindow->SetView(
+      glass::MakeFunctionView([] { gNetworkTablesSettings->Display(); }));
+  gNetworkTablesSettingsWindow->SetDefaultPos(30, 30);
+  gNetworkTablesSettingsWindow->SetFlags(ImGuiWindowFlags_AlwaysAutoResize);
+  gNetworkTablesSettingsWindow->DisableRenamePopup();
+  gui::AddLateExecute([] { gNetworkTablesSettingsWindow->Display(); });
+
+  gui::AddWindowScaler([](float scale) {
+    // scale default window positions
+    gNetworkTablesLogWindow->ScaleDefault(scale);
+    gNetworkTablesWindow->ScaleDefault(scale);
+    gNetworkTablesSettingsWindow->ScaleDefault(scale);
+  });
 }
 
 #ifdef _WIN32
 int __stdcall WinMain(void* hInstance, void* hPrevInstance, char* pCmdLine,
                       int nCmdShow) {
+  int argc = __argc;
+  char** argv = __argv;
 #else
-int main() {
+int main(int argc, char** argv) {
 #endif
+  std::string_view saveDir;
+  if (argc == 2) {
+    saveDir = argv[1];
+  }
+
   gui::CreateContext();
   glass::CreateContext();
 
@@ -137,21 +161,28 @@
   gui::AddIcon(glass::GetResource_glass_256_png());
   gui::AddIcon(glass::GetResource_glass_512_png());
 
-  gPlotProvider = std::make_unique<glass::PlotProvider>("Plot");
-  gNtProvider = std::make_unique<glass::NetworkTablesProvider>("NTProvider");
+  gPlotProvider = std::make_unique<glass::PlotProvider>(
+      glass::GetStorageRoot().GetChild("Plots"));
+  gNtProvider = std::make_unique<glass::NetworkTablesProvider>(
+      glass::GetStorageRoot().GetChild("NetworkTables"));
 
-  gui::ConfigurePlatformSaveFile("glass.ini");
+  glass::SetStorageName("glass");
+  glass::SetStorageDir(saveDir.empty() ? gui::GetPlatformSaveFileDir()
+                                       : saveDir);
   gPlotProvider->GlobalInit();
   gui::AddInit([] { glass::ResetTime(); });
   gNtProvider->GlobalInit();
-  gui::AddInit(NtInitialize);
+  NtInitialize();
 
   glass::AddStandardNetworkTablesViews(*gNtProvider);
 
-  gui::AddLateExecute([] {
-    ImGui::BeginMainMenuBar();
-    gui::EmitViewMenu();
+  gui::AddLateExecute([] { gMainMenu.Display(); });
+
+  gMainMenu.AddMainMenu([] {
     if (ImGui::BeginMenu("View")) {
+      if (ImGui::MenuItem("Set Enter Key")) {
+        gSetEnterKey = true;
+      }
       if (ImGui::MenuItem("Reset Time")) {
         glass::ResetTime();
       }
@@ -181,38 +212,95 @@
       ImGui::EndMenu();
     }
 
-    bool about = false;
     if (ImGui::BeginMenu("Info")) {
       if (ImGui::MenuItem("About")) {
-        about = true;
+        gAbout = true;
       }
       ImGui::EndMenu();
     }
-    ImGui::EndMainMenuBar();
+  });
 
-    if (about) {
+  gui::AddLateExecute([] {
+    if (gAbout) {
       ImGui::OpenPopup("About");
-      about = false;
+      gAbout = false;
     }
     if (ImGui::BeginPopupModal("About")) {
       ImGui::Text("Glass: A different kind of dashboard");
       ImGui::Separator();
       ImGui::Text("v%s", GetWPILibVersion());
+      ImGui::Separator();
+      ImGui::Text("Save location: %s", glass::GetStorageDir().c_str());
       if (ImGui::Button("Close")) {
         ImGui::CloseCurrentPopup();
       }
       ImGui::EndPopup();
     }
+
+    int& enterKey = glass::GetStorageRoot().GetInt("enterKey", GLFW_KEY_ENTER);
+
+    ImGuiIO& io = ImGui::GetIO();
+    io.KeyMap[ImGuiKey_Enter] = enterKey;
+
+    if (gSetEnterKey) {
+      ImGui::OpenPopup("Set Enter Key");
+      gSetEnterKey = false;
+    }
+    if (ImGui::BeginPopupModal("Set Enter Key")) {
+      ImGui::Text("Set the key to use to mean 'Enter'");
+      ImGui::Text("This is useful to edit values without the DS disabling");
+      ImGui::Separator();
+
+      if (gKeyEdit) {
+        for (int i = 0; i < IM_ARRAYSIZE(io.KeysDown); ++i) {
+          if (io.KeysDown[i]) {
+            // remove all other uses
+            enterKey = i;
+            gKeyEdit = false;
+            break;
+          }
+        }
+      }
+
+      ImGui::Text("Key:");
+      ImGui::SameLine();
+      char editLabel[40];
+      char nameBuf[32];
+      const char* name = glfwGetKeyName(enterKey, 0);
+      if (!name) {
+        std::snprintf(nameBuf, sizeof(nameBuf), "%d", enterKey);
+        name = nameBuf;
+      }
+      std::snprintf(editLabel, sizeof(editLabel), "%s###edit",
+                    gKeyEdit ? "(press key)" : name);
+      if (ImGui::SmallButton(editLabel)) {
+        gKeyEdit = true;
+      }
+      ImGui::SameLine();
+      if (ImGui::SmallButton("Reset")) {
+        enterKey = GLFW_KEY_ENTER;
+      }
+
+      if (ImGui::Button("Close")) {
+        ImGui::CloseCurrentPopup();
+        gKeyEdit = false;
+      }
+      ImGui::EndPopup();
+    }
   });
 
   gui::Initialize("Glass - DISCONNECTED", 1024, 768);
   gui::Main();
 
+  gNetworkTablesSettingsWindow.reset();
+  gNetworkTablesLogWindow.reset();
+  gNetworkTablesWindow.reset();
   gNetworkTablesModel.reset();
-  gNetworkTablesSettings.reset();
   gNtProvider.reset();
   gPlotProvider.reset();
 
   glass::DestroyContext();
   gui::DestroyContext();
+
+  return 0;
 }
diff --git a/glass/src/lib/native/cpp/Context.cpp b/glass/src/lib/native/cpp/Context.cpp
index 936acb2..1de4af8 100644
--- a/glass/src/lib/native/cpp/Context.cpp
+++ b/glass/src/lib/native/cpp/Context.cpp
@@ -7,13 +7,21 @@
 #include <algorithm>
 #include <cinttypes>
 #include <cstdio>
+#include <filesystem>
 
+#include <fmt/format.h>
 #include <imgui.h>
 #include <imgui_internal.h>
 #include <imgui_stdlib.h>
 #include <wpi/StringExtras.h>
+#include <wpi/fs.h>
+#include <wpi/json.h>
+#include <wpi/json_serializer.h>
+#include <wpi/raw_istream.h>
+#include <wpi/raw_ostream.h>
 #include <wpi/timestamp.h>
 #include <wpigui.h>
+#include <wpigui_internal.h>
 
 #include "glass/ContextInternal.h"
 
@@ -21,172 +29,292 @@
 
 Context* glass::gContext;
 
-static bool ConvertInt(Storage::Value* value) {
-  value->type = Storage::Value::kInt;
-  if (auto val = wpi::parse_integer<int>(value->stringVal, 10)) {
-    value->intVal = val.value();
-    return true;
+static void WorkspaceResetImpl() {
+  // call reset functions
+  for (auto&& reset : gContext->workspaceReset) {
+    if (reset) {
+      reset();
+    }
   }
-  return false;
+
+  // clear storage
+  for (auto&& root : gContext->storageRoots) {
+    root.second->Clear();
+  }
+
+  // ImGui reset
+  ImGui::ClearIniSettings();
 }
 
-static bool ConvertInt64(Storage::Value* value) {
-  value->type = Storage::Value::kInt64;
-  if (auto val = wpi::parse_integer<int64_t>(value->stringVal, 10)) {
-    value->int64Val = val.value();
-    return true;
+static void WorkspaceInit() {
+  for (auto&& init : gContext->workspaceInit) {
+    if (init) {
+      init();
+    }
   }
-  return false;
+
+  for (auto&& root : gContext->storageRoots) {
+    root.getValue()->Apply();
+  }
 }
 
-static bool ConvertBool(Storage::Value* value) {
-  value->type = Storage::Value::kBool;
-  if (auto val = wpi::parse_integer<int>(value->stringVal, 10)) {
-    value->intVal = (val.value() != 0);
-    return true;
+static bool JsonToWindow(const wpi::json& jfile, const char* filename) {
+  if (!jfile.is_object()) {
+    ImGui::LogText("%s top level is not object", filename);
+    return false;
   }
-  return false;
+
+  // loop over JSON and generate ini format
+  std::string iniStr;
+  wpi::raw_string_ostream ini{iniStr};
+
+  for (auto&& jsection : jfile.items()) {
+    if (!jsection.value().is_object()) {
+      ImGui::LogText("%s section %s is not object", filename,
+                     jsection.key().c_str());
+      return false;
+    }
+    for (auto&& jsubsection : jsection.value().items()) {
+      if (!jsubsection.value().is_object()) {
+        ImGui::LogText("%s section %s subsection %s is not object", filename,
+                       jsection.key().c_str(), jsubsection.key().c_str());
+        return false;
+      }
+      ini << '[' << jsection.key() << "][" << jsubsection.key() << "]\n";
+      for (auto&& jkv : jsubsection.value().items()) {
+        try {
+          auto& value = jkv.value().get_ref<const std::string&>();
+          ini << jkv.key() << '=' << value << "\n";
+        } catch (wpi::json::exception&) {
+          ImGui::LogText("%s section %s subsection %s value %s is not string",
+                         filename, jsection.key().c_str(),
+                         jsubsection.key().c_str(), jkv.key().c_str());
+          return false;
+        }
+      }
+      ini << '\n';
+    }
+  }
+  ini.flush();
+
+  ImGui::LoadIniSettingsFromMemory(iniStr.data(), iniStr.size());
+  return true;
 }
 
-static bool ConvertFloat(Storage::Value* value) {
-  value->type = Storage::Value::kFloat;
-  if (auto val = wpi::parse_float<float>(value->stringVal)) {
-    value->floatVal = val.value();
-    return true;
-  }
-  return false;
-}
-
-static bool ConvertDouble(Storage::Value* value) {
-  value->type = Storage::Value::kDouble;
-  if (auto val = wpi::parse_float<double>(value->stringVal)) {
-    value->doubleVal = val.value();
-    return true;
-  }
-  return false;
-}
-
-static void* GlassStorageReadOpen(ImGuiContext*, ImGuiSettingsHandler* handler,
-                                  const char* name) {
-  auto ctx = static_cast<Context*>(handler->UserData);
-  auto& storage = ctx->storage[name];
-  if (!storage) {
-    storage = std::make_unique<Storage>();
-  }
-  return storage.get();
-}
-
-static void GlassStorageReadLine(ImGuiContext*, ImGuiSettingsHandler*,
-                                 void* entry, const char* line) {
-  auto storage = static_cast<Storage*>(entry);
-  auto [key, val] = wpi::split(line, '=');
-  auto& keys = storage->GetKeys();
-  auto& values = storage->GetValues();
-  auto it = std::find(keys.begin(), keys.end(), key);
-  if (it == keys.end()) {
-    keys.emplace_back(key);
-    values.emplace_back(std::make_unique<Storage::Value>(val));
+static bool LoadWindowStorageImpl(const std::string& filename) {
+  std::error_code ec;
+  wpi::raw_fd_istream is{filename, ec};
+  if (ec) {
+    ImGui::LogText("error opening %s: %s", filename.c_str(),
+                   ec.message().c_str());
+    return false;
   } else {
-    auto& value = *values[it - keys.begin()];
-    value.stringVal = val;
-    switch (value.type) {
-      case Storage::Value::kInt:
-        ConvertInt(&value);
-        break;
-      case Storage::Value::kInt64:
-        ConvertInt64(&value);
-        break;
-      case Storage::Value::kBool:
-        ConvertBool(&value);
-        break;
-      case Storage::Value::kFloat:
-        ConvertFloat(&value);
-        break;
-      case Storage::Value::kDouble:
-        ConvertDouble(&value);
-        break;
-      default:
-        break;
+    try {
+      return JsonToWindow(wpi::json::parse(is), filename.c_str());
+    } catch (wpi::json::parse_error& e) {
+      ImGui::LogText("Error loading %s: %s", filename.c_str(), e.what());
+      return false;
     }
   }
 }
 
-static void GlassStorageWriteAll(ImGuiContext*, ImGuiSettingsHandler* handler,
-                                 ImGuiTextBuffer* out_buf) {
-  auto ctx = static_cast<Context*>(handler->UserData);
-
-  // sort for output
-  std::vector<wpi::StringMapConstIterator<std::unique_ptr<Storage>>> sorted;
-  for (auto it = ctx->storage.begin(); it != ctx->storage.end(); ++it) {
-    sorted.emplace_back(it);
+static bool LoadStorageRootImpl(Context* ctx, const std::string& filename,
+                                std::string_view rootName) {
+  std::error_code ec;
+  wpi::raw_fd_istream is{filename, ec};
+  if (ec) {
+    ImGui::LogText("error opening %s: %s", filename.c_str(),
+                   ec.message().c_str());
+    return false;
+  } else {
+    auto& storage = ctx->storageRoots[rootName];
+    bool createdStorage = false;
+    if (!storage) {
+      storage = std::make_unique<Storage>();
+      createdStorage = true;
+    }
+    try {
+      storage->FromJson(wpi::json::parse(is), filename.c_str());
+    } catch (wpi::json::parse_error& e) {
+      ImGui::LogText("Error loading %s: %s", filename.c_str(), e.what());
+      if (createdStorage) {
+        ctx->storageRoots.erase(rootName);
+      }
+      return false;
+    }
   }
-  std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) {
-    return a->getKey() < b->getKey();
-  });
+  return true;
+}
 
-  for (auto&& entryIt : sorted) {
-    auto& entry = *entryIt;
-    out_buf->append("[GlassStorage][");
-    out_buf->append(entry.first().data(),
-                    entry.first().data() + entry.first().size());
-    out_buf->append("]\n");
-    auto& keys = entry.second->GetKeys();
-    auto& values = entry.second->GetValues();
-    for (size_t i = 0; i < keys.size(); ++i) {
-      out_buf->append(keys[i].data(), keys[i].data() + keys[i].size());
-      out_buf->append("=");
-      auto& value = *values[i];
-      switch (value.type) {
-        case Storage::Value::kInt:
-          out_buf->appendf("%d\n", value.intVal);
-          break;
-        case Storage::Value::kInt64:
-          out_buf->appendf("%" PRId64 "\n", value.int64Val);
-          break;
-        case Storage::Value::kBool:
-          out_buf->appendf("%d\n", value.boolVal ? 1 : 0);
-          break;
-        case Storage::Value::kFloat:
-          out_buf->appendf("%f\n", value.floatVal);
-          break;
-        case Storage::Value::kDouble:
-          out_buf->appendf("%f\n", value.doubleVal);
-          break;
-        case Storage::Value::kNone:
-        case Storage::Value::kString:
-          out_buf->append(value.stringVal.data(),
-                          value.stringVal.data() + value.stringVal.size());
-          out_buf->append("\n");
-          break;
+static bool LoadStorageImpl(Context* ctx, std::string_view dir,
+                            std::string_view name) {
+  WorkspaceResetImpl();
+
+  bool rv = true;
+  for (auto&& root : ctx->storageRoots) {
+    std::string filename;
+    auto rootName = root.getKey();
+    if (rootName.empty()) {
+      filename = (fs::path{dir} / fmt::format("{}.json", name)).string();
+    } else {
+      filename =
+          (fs::path{dir} / fmt::format("{}-{}.json", name, rootName)).string();
+    }
+    if (!LoadStorageRootImpl(ctx, filename, rootName)) {
+      rv = false;
+    }
+  }
+
+  WorkspaceInit();
+  return rv;
+}
+
+static wpi::json WindowToJson() {
+  size_t iniLen;
+  const char* iniData = ImGui::SaveIniSettingsToMemory(&iniLen);
+  std::string_view ini{iniData, iniLen};
+
+  // parse the ini data and build JSON
+  // JSON format:
+  // {
+  //   "Section": {
+  //     "Subsection": {
+  //       "Key": "Value"  // all values are saved as strings
+  //     }
+  //   }
+  // }
+
+  wpi::json out = wpi::json::object();
+  wpi::json* curSection = nullptr;
+  while (!ini.empty()) {
+    std::string_view line;
+    std::tie(line, ini) = wpi::split(ini, '\n');
+    line = wpi::trim(line);
+    if (line.empty()) {
+      continue;
+    }
+    if (line[0] == '[') {
+      // new section
+      auto [section, subsection] = wpi::split(line, ']');
+      section = wpi::drop_front(section);  // drop '['; ']' was dropped by split
+      subsection = wpi::drop_back(wpi::drop_front(subsection));  // drop []
+      auto& jsection = out[section];
+      if (jsection.is_null()) {
+        jsection = wpi::json::object();
+      }
+      curSection = &jsection[subsection];
+      if (curSection->is_null()) {
+        *curSection = wpi::json::object();
+      }
+    } else {
+      // value
+      if (!curSection) {
+        continue;  // shouldn't happen, but just in case
+      }
+      auto [name, value] = wpi::split(line, '=');
+      (*curSection)[name] = value;
+    }
+  }
+
+  return out;
+}
+
+bool SaveWindowStorageImpl(const std::string& filename) {
+  std::error_code ec;
+  wpi::raw_fd_ostream os{filename, ec};
+  if (ec) {
+    ImGui::LogText("error opening %s: %s", filename.c_str(),
+                   ec.message().c_str());
+    return false;
+  }
+  WindowToJson().dump(os, 2);
+  os << '\n';
+  return true;
+}
+
+static bool SaveStorageRootImpl(Context* ctx, const std::string& filename,
+                                const Storage& storage) {
+  std::error_code ec;
+  wpi::raw_fd_ostream os{filename, ec};
+  if (ec) {
+    ImGui::LogText("error opening %s: %s", filename.c_str(),
+                   ec.message().c_str());
+    return false;
+  }
+  storage.ToJson().dump(os, 2);
+  os << '\n';
+  return true;
+}
+
+static bool SaveStorageImpl(Context* ctx, std::string_view dir,
+                            std::string_view name, bool exiting) {
+  fs::path dirPath{dir};
+
+  std::error_code ec;
+  fs::create_directories(dirPath, ec);
+  if (ec) {
+    return false;
+  }
+
+  // handle erasing save files on exit if requested
+  if (exiting && wpi::gui::gContext->resetOnExit) {
+    fs::remove(dirPath / fmt::format("{}-window.json", name), ec);
+    for (auto&& root : ctx->storageRoots) {
+      auto rootName = root.getKey();
+      if (rootName.empty()) {
+        fs::remove(dirPath / fmt::format("{}.json", name), ec);
+      } else {
+        fs::remove(dirPath / fmt::format("{}-{}.json", name, rootName), ec);
       }
     }
-    out_buf->append("\n");
   }
+
+  bool rv = SaveWindowStorageImpl(
+      (dirPath / fmt::format("{}-window.json", name)).string());
+
+  for (auto&& root : ctx->storageRoots) {
+    auto rootName = root.getKey();
+    std::string filename;
+    if (rootName.empty()) {
+      filename = (dirPath / fmt::format("{}.json", name)).string();
+    } else {
+      filename = (dirPath / fmt::format("{}-{}.json", name, rootName)).string();
+    }
+    if (!SaveStorageRootImpl(ctx, filename, *root.getValue())) {
+      rv = false;
+    }
+  }
+  return rv;
 }
 
-static void Initialize(Context* ctx) {
-  wpi::gui::AddInit([=] {
-    ImGuiSettingsHandler ini_handler;
-    ini_handler.TypeName = "GlassStorage";
-    ini_handler.TypeHash = ImHashStr("GlassStorage");
-    ini_handler.ReadOpenFn = GlassStorageReadOpen;
-    ini_handler.ReadLineFn = GlassStorageReadLine;
-    ini_handler.WriteAllFn = GlassStorageWriteAll;
-    ini_handler.UserData = ctx;
-    ImGui::GetCurrentContext()->SettingsHandlers.push_back(ini_handler);
+Context::Context()
+    : sourceNameStorage{storageRoots.insert({"", std::make_unique<Storage>()})
+                            .first->getValue()
+                            ->GetChild("sourceNames")} {
+  storageStack.emplace_back(storageRoots[""].get());
 
-    ctx->sources.Initialize();
-  });
+  // override ImGui ini saving
+  wpi::gui::ConfigureCustomSaveSettings(
+      [this] { LoadStorageImpl(this, storageLoadDir, storageName); },
+      [this] {
+        LoadWindowStorageImpl((fs::path{storageLoadDir} /
+                               fmt::format("{}-window.json", storageName))
+                                  .string());
+      },
+      [this](bool exiting) {
+        SaveStorageImpl(this, storageAutoSaveDir, storageName, exiting);
+      });
 }
 
-static void Shutdown(Context* ctx) {}
+Context::~Context() {
+  wpi::gui::ConfigureCustomSaveSettings(nullptr, nullptr, nullptr);
+}
 
 Context* glass::CreateContext() {
   Context* ctx = new Context;
   if (!gContext) {
     SetCurrentContext(ctx);
   }
-  Initialize(ctx);
   return ctx;
 }
 
@@ -194,7 +322,6 @@
   if (!ctx) {
     ctx = gContext;
   }
-  Shutdown(ctx);
   if (gContext == ctx) {
     SetCurrentContext(nullptr);
   }
@@ -217,215 +344,167 @@
   return gContext->zeroTime;
 }
 
-Storage::Value& Storage::GetValue(std::string_view key) {
-  auto it = std::find(m_keys.begin(), m_keys.end(), key);
-  if (it == m_keys.end()) {
-    m_keys.emplace_back(key);
-    m_values.emplace_back(std::make_unique<Value>());
-    return *m_values.back();
-  } else {
-    return *m_values[it - m_keys.begin()];
+void glass::WorkspaceReset() {
+  WorkspaceResetImpl();
+  WorkspaceInit();
+}
+
+void glass::AddWorkspaceInit(std::function<void()> init) {
+  if (init) {
+    gContext->workspaceInit.emplace_back(std::move(init));
   }
 }
 
-#define DEFUN(CapsName, LowerName, CType)                                      \
-  CType Storage::Get##CapsName(std::string_view key, CType defaultVal) const { \
-    auto it = std::find(m_keys.begin(), m_keys.end(), key);                    \
-    if (it == m_keys.end())                                                    \
-      return defaultVal;                                                       \
-    Value& value = *m_values[it - m_keys.begin()];                             \
-    if (value.type != Value::k##CapsName) {                                    \
-      if (!Convert##CapsName(&value))                                          \
-        value.LowerName##Val = defaultVal;                                     \
-    }                                                                          \
-    return value.LowerName##Val;                                               \
-  }                                                                            \
-                                                                               \
-  void Storage::Set##CapsName(std::string_view key, CType val) {               \
-    auto it = std::find(m_keys.begin(), m_keys.end(), key);                    \
-    if (it == m_keys.end()) {                                                  \
-      m_keys.emplace_back(key);                                                \
-      m_values.emplace_back(std::make_unique<Value>());                        \
-      m_values.back()->type = Value::k##CapsName;                              \
-      m_values.back()->LowerName##Val = val;                                   \
-    } else {                                                                   \
-      Value& value = *m_values[it - m_keys.begin()];                           \
-      value.type = Value::k##CapsName;                                         \
-      value.LowerName##Val = val;                                              \
-    }                                                                          \
-  }                                                                            \
-                                                                               \
-  CType* Storage::Get##CapsName##Ref(std::string_view key, CType defaultVal) { \
-    auto it = std::find(m_keys.begin(), m_keys.end(), key);                    \
-    if (it == m_keys.end()) {                                                  \
-      m_keys.emplace_back(key);                                                \
-      m_values.emplace_back(std::make_unique<Value>());                        \
-      m_values.back()->type = Value::k##CapsName;                              \
-      m_values.back()->LowerName##Val = defaultVal;                            \
-      return &m_values.back()->LowerName##Val;                                 \
-    } else {                                                                   \
-      Value& value = *m_values[it - m_keys.begin()];                           \
-      if (value.type != Value::k##CapsName) {                                  \
-        if (!Convert##CapsName(&value))                                        \
-          value.LowerName##Val = defaultVal;                                   \
-      }                                                                        \
-      return &value.LowerName##Val;                                            \
-    }                                                                          \
-  }
-
-DEFUN(Int, int, int)
-DEFUN(Int64, int64, int64_t)
-DEFUN(Bool, bool, bool)
-DEFUN(Float, float, float)
-DEFUN(Double, double, double)
-
-std::string Storage::GetString(std::string_view key,
-                               std::string_view defaultVal) const {
-  auto it = std::find(m_keys.begin(), m_keys.end(), key);
-  if (it == m_keys.end()) {
-    return std::string{defaultVal};
-  }
-  Value& value = *m_values[it - m_keys.begin()];
-  value.type = Value::kString;
-  return value.stringVal;
-}
-
-void Storage::SetString(std::string_view key, std::string_view val) {
-  auto it = std::find(m_keys.begin(), m_keys.end(), key);
-  if (it == m_keys.end()) {
-    m_keys.emplace_back(key);
-    m_values.emplace_back(std::make_unique<Value>(val));
-    m_values.back()->type = Value::kString;
-  } else {
-    Value& value = *m_values[it - m_keys.begin()];
-    value.type = Value::kString;
-    value.stringVal = val;
+void glass::AddWorkspaceReset(std::function<void()> reset) {
+  if (reset) {
+    gContext->workspaceReset.emplace_back(std::move(reset));
   }
 }
 
-std::string* Storage::GetStringRef(std::string_view key,
-                                   std::string_view defaultVal) {
-  auto it = std::find(m_keys.begin(), m_keys.end(), key);
-  if (it == m_keys.end()) {
-    m_keys.emplace_back(key);
-    m_values.emplace_back(std::make_unique<Value>(defaultVal));
-    m_values.back()->type = Value::kString;
-    return &m_values.back()->stringVal;
+void glass::SetStorageName(std::string_view name) {
+  gContext->storageName = name;
+}
+
+void glass::SetStorageDir(std::string_view dir) {
+  if (dir.empty()) {
+    gContext->storageLoadDir = ".";
+    gContext->storageAutoSaveDir = ".";
   } else {
-    Value& value = *m_values[it - m_keys.begin()];
-    value.type = Value::kString;
-    return &value.stringVal;
+    gContext->storageLoadDir = dir;
+    gContext->storageAutoSaveDir = dir;
+    gContext->isPlatformSaveDir = (dir == wpi::gui::GetPlatformSaveFileDir());
   }
 }
 
+std::string glass::GetStorageDir() {
+  return gContext->storageAutoSaveDir;
+}
+
+bool glass::LoadStorage(std::string_view dir) {
+  SaveStorage();
+  SetStorageDir(dir);
+  LoadWindowStorageImpl((fs::path{gContext->storageLoadDir} /
+                         fmt::format("{}-window.json", gContext->storageName))
+                            .string());
+  return LoadStorageImpl(gContext, dir, gContext->storageName);
+}
+
+bool glass::SaveStorage() {
+  return SaveStorageImpl(gContext, gContext->storageAutoSaveDir,
+                         gContext->storageName, false);
+}
+
+bool glass::SaveStorage(std::string_view dir) {
+  return SaveStorageImpl(gContext, dir, gContext->storageName, false);
+}
+
+Storage& glass::GetCurStorageRoot() {
+  return *gContext->storageStack.front();
+}
+
+Storage& glass::GetStorageRoot(std::string_view rootName) {
+  auto& storage = gContext->storageRoots[rootName];
+  if (!storage) {
+    storage = std::make_unique<Storage>();
+  }
+  return *storage;
+}
+
+void glass::ResetStorageStack(std::string_view rootName) {
+  if (gContext->storageStack.size() != 1) {
+    ImGui::LogText("resetting non-empty storage stack");
+  }
+  gContext->storageStack.clear();
+  gContext->storageStack.emplace_back(&GetStorageRoot(rootName));
+}
+
 Storage& glass::GetStorage() {
-  auto& storage = gContext->storage[gContext->curId];
-  if (!storage) {
-    storage = std::make_unique<Storage>();
-  }
-  return *storage;
+  return *gContext->storageStack.back();
 }
 
-Storage& glass::GetStorage(std::string_view id) {
-  auto& storage = gContext->storage[id];
-  if (!storage) {
-    storage = std::make_unique<Storage>();
-  }
-  return *storage;
+void glass::PushStorageStack(std::string_view label_id) {
+  gContext->storageStack.emplace_back(
+      &gContext->storageStack.back()->GetChild(label_id));
 }
 
-static void PushIDStack(std::string_view label_id) {
-  gContext->idStack.emplace_back(gContext->curId.size());
-
-  auto [label, id] = wpi::split(label_id, "###");
-  // if no ###id, use label as id
-  if (id.empty()) {
-    id = label;
-  }
-  if (!gContext->curId.empty()) {
-    gContext->curId += "###";
-  }
-  gContext->curId += id;
+void glass::PushStorageStack(Storage& storage) {
+  gContext->storageStack.emplace_back(&storage);
 }
 
-static void PopIDStack() {
-  gContext->curId.resize(gContext->idStack.back());
-  gContext->idStack.pop_back();
+void glass::PopStorageStack() {
+  if (gContext->storageStack.size() <= 1) {
+    ImGui::LogText("attempted to pop empty storage stack, mismatch push/pop?");
+    return;  // ignore
+  }
+  gContext->storageStack.pop_back();
 }
 
 bool glass::Begin(const char* name, bool* p_open, ImGuiWindowFlags flags) {
-  PushIDStack(name);
+  PushStorageStack(name);
   return ImGui::Begin(name, p_open, flags);
 }
 
 void glass::End() {
   ImGui::End();
-  PopIDStack();
+  PopStorageStack();
 }
 
 bool glass::BeginChild(const char* str_id, const ImVec2& size, bool border,
                        ImGuiWindowFlags flags) {
-  PushIDStack(str_id);
+  PushStorageStack(str_id);
   return ImGui::BeginChild(str_id, size, border, flags);
 }
 
 void glass::EndChild() {
   ImGui::EndChild();
-  PopIDStack();
+  PopStorageStack();
 }
 
 bool glass::CollapsingHeader(const char* label, ImGuiTreeNodeFlags flags) {
-  wpi::SmallString<64> openKey;
-  auto [name, id] = wpi::split(label, "###");
-  // if no ###id, use name as id
-  if (id.empty()) {
-    id = name;
-  }
-  openKey = id;
-  openKey += "###open";
-
-  bool* open = GetStorage().GetBoolRef(openKey.str());
-  *open = ImGui::CollapsingHeader(
-      label, flags | (*open ? ImGuiTreeNodeFlags_DefaultOpen : 0));
-  return *open;
+  bool& open = GetStorage().GetChild(label).GetBool(
+      "open", (flags & ImGuiTreeNodeFlags_DefaultOpen) != 0);
+  ImGui::SetNextItemOpen(open);
+  open = ImGui::CollapsingHeader(label, flags);
+  return open;
 }
 
 bool glass::TreeNodeEx(const char* label, ImGuiTreeNodeFlags flags) {
-  PushIDStack(label);
-  bool* open = GetStorage().GetBoolRef("open");
-  *open = ImGui::TreeNodeEx(
-      label, flags | (*open ? ImGuiTreeNodeFlags_DefaultOpen : 0));
-  if (!*open) {
-    PopIDStack();
+  PushStorageStack(label);
+  bool& open = GetStorage().GetBool(
+      "open", (flags & ImGuiTreeNodeFlags_DefaultOpen) != 0);
+  ImGui::SetNextItemOpen(open);
+  open = ImGui::TreeNodeEx(label, flags);
+  if (!open) {
+    PopStorageStack();
   }
-  return *open;
+  return open;
 }
 
 void glass::TreePop() {
   ImGui::TreePop();
-  PopIDStack();
+  PopStorageStack();
 }
 
 void glass::PushID(const char* str_id) {
-  PushIDStack(str_id);
+  PushStorageStack(str_id);
   ImGui::PushID(str_id);
 }
 
 void glass::PushID(const char* str_id_begin, const char* str_id_end) {
-  PushIDStack(std::string_view(str_id_begin, str_id_end - str_id_begin));
+  PushStorageStack(std::string_view(str_id_begin, str_id_end - str_id_begin));
   ImGui::PushID(str_id_begin, str_id_end);
 }
 
 void glass::PushID(int int_id) {
   char buf[16];
   std::snprintf(buf, sizeof(buf), "%d", int_id);
-  PushIDStack(buf);
+  PushStorageStack(buf);
   ImGui::PushID(int_id);
 }
 
 void glass::PopID() {
   ImGui::PopID();
-  PopIDStack();
+  PopStorageStack();
 }
 
 bool glass::PopupEditName(const char* label, std::string* name) {
diff --git a/glass/src/lib/native/cpp/DataSource.cpp b/glass/src/lib/native/cpp/DataSource.cpp
index adab6e7..d9bee62 100644
--- a/glass/src/lib/native/cpp/DataSource.cpp
+++ b/glass/src/lib/native/cpp/DataSource.cpp
@@ -12,13 +12,9 @@
 
 wpi::sig::Signal<const char*, DataSource*> DataSource::sourceCreated;
 
-DataSource::DataSource(std::string_view id) : m_id{id} {
-  auto it = gContext->sources.try_emplace(m_id, this);
-  auto& srcName = it.first->getValue();
-  m_name = srcName.name.get();
-  if (!srcName.source) {
-    srcName.source = this;
-  }
+DataSource::DataSource(std::string_view id)
+    : m_id{id}, m_name{gContext->sourceNameStorage.GetString(m_id)} {
+  gContext->sources.try_emplace(m_id, this);
   sourceCreated(m_id.c_str(), this);
 }
 
@@ -32,43 +28,7 @@
   if (!gContext) {
     return;
   }
-  auto it = gContext->sources.find(m_id);
-  if (it == gContext->sources.end()) {
-    return;
-  }
-  auto& srcName = it->getValue();
-  if (srcName.source == this) {
-    srcName.source = nullptr;
-  }
-}
-
-void DataSource::SetName(std::string_view name) {
-  m_name->SetName(name);
-}
-
-const char* DataSource::GetName() const {
-  return m_name->GetName();
-}
-
-void DataSource::PushEditNameId(int index) {
-  m_name->PushEditNameId(index);
-}
-
-void DataSource::PushEditNameId(const char* name) {
-  m_name->PushEditNameId(name);
-}
-
-bool DataSource::PopupEditName(int index) {
-  return m_name->PopupEditName(index);
-}
-
-bool DataSource::PopupEditName(const char* name) {
-  return m_name->PopupEditName(name);
-}
-
-bool DataSource::InputTextName(const char* label_id,
-                               ImGuiInputTextFlags flags) {
-  return m_name->InputTextName(label_id, flags);
+  gContext->sources.erase(m_id);
 }
 
 void DataSource::LabelText(const char* label, const char* fmt, ...) const {
@@ -82,7 +42,7 @@
 void DataSource::LabelTextV(const char* label, const char* fmt,
                             va_list args) const {
   ImGui::PushID(label);
-  ImGui::LabelTextV("##input", fmt, args);
+  ImGui::LabelTextV("##input", fmt, args);  // NOLINT
   ImGui::SameLine(0, ImGui::GetStyle().ItemInnerSpacing.x);
   ImGui::Selectable(label);
   ImGui::PopID();
@@ -141,7 +101,7 @@
   if (ImGui::BeginDragDropSource(flags)) {
     auto self = this;
     ImGui::SetDragDropPayload("DataSource", &self, sizeof(self));  // NOLINT
-    const char* name = GetName();
+    const char* name = GetName().c_str();
     ImGui::TextUnformatted(name[0] == '\0' ? m_id.c_str() : name);
     ImGui::EndDragDropSource();
   }
@@ -152,5 +112,5 @@
   if (it == gContext->sources.end()) {
     return nullptr;
   }
-  return it->getValue().source;
+  return it->getValue();
 }
diff --git a/glass/src/lib/native/cpp/MainMenuBar.cpp b/glass/src/lib/native/cpp/MainMenuBar.cpp
index 2c4d371..879f664 100644
--- a/glass/src/lib/native/cpp/MainMenuBar.cpp
+++ b/glass/src/lib/native/cpp/MainMenuBar.cpp
@@ -6,8 +6,12 @@
 
 #include <cstdio>
 
+#include <imgui.h>
 #include <wpigui.h>
 
+#include "glass/Context.h"
+#include "glass/ContextInternal.h"
+
 using namespace glass;
 
 void MainMenuBar::AddMainMenu(std::function<void()> menu) {
@@ -25,6 +29,8 @@
 void MainMenuBar::Display() {
   ImGui::BeginMainMenuBar();
 
+  WorkspaceMenu();
+
   if (!m_optionMenus.empty()) {
     if (ImGui::BeginMenu("Options")) {
       for (auto&& menu : m_optionMenus) {
@@ -55,3 +61,46 @@
 #endif
   ImGui::EndMainMenuBar();
 }
+
+void MainMenuBar::WorkspaceMenu() {
+  if (ImGui::BeginMenu("Workspace")) {
+    if (ImGui::MenuItem("Open...")) {
+      m_openFolder =
+          std::make_unique<pfd::select_folder>("Choose folder to open");
+    }
+    if (ImGui::MenuItem("Save As...")) {
+      m_saveFolder = std::make_unique<pfd::select_folder>("Choose save folder");
+    }
+    if (ImGui::MenuItem("Save As Global", nullptr, false,
+                        !gContext->isPlatformSaveDir)) {
+      SetStorageDir(wpi::gui::GetPlatformSaveFileDir());
+      SaveStorage();
+    }
+    ImGui::Separator();
+    if (ImGui::MenuItem("Reset")) {
+      WorkspaceReset();
+    }
+    ImGui::Separator();
+    if (ImGui::MenuItem("Exit")) {
+      wpi::gui::Exit();
+    }
+    ImGui::EndMenu();
+  }
+
+  if (m_openFolder && m_openFolder->ready(0)) {
+    auto result = m_openFolder->result();
+    if (!result.empty()) {
+      LoadStorage(result);
+    }
+    m_openFolder.reset();
+  }
+
+  if (m_saveFolder && m_saveFolder->ready(0)) {
+    auto result = m_saveFolder->result();
+    if (!result.empty()) {
+      SetStorageDir(result);
+      SaveStorage(result);
+    }
+    m_saveFolder.reset();
+  }
+}
diff --git a/glass/src/lib/native/cpp/Storage.cpp b/glass/src/lib/native/cpp/Storage.cpp
new file mode 100644
index 0000000..28af20b
--- /dev/null
+++ b/glass/src/lib/native/cpp/Storage.cpp
@@ -0,0 +1,688 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "glass/Storage.h"
+
+#include <type_traits>
+
+#include <imgui.h>
+#include <wpi/StringExtras.h>
+#include <wpi/json.h>
+
+using namespace glass;
+
+template <typename To>
+bool ConvertFromString(To* out, std::string_view str) {
+  if constexpr (std::is_same_v<To, bool>) {
+    if (str == "true") {
+      *out = true;
+    } else if (str == "false") {
+      *out = false;
+    } else if (auto val = wpi::parse_integer<int>(str, 10)) {
+      *out = val.value() != 0;
+    } else {
+      return false;
+    }
+  } else if constexpr (std::is_floating_point_v<To>) {
+    if (auto val = wpi::parse_float<To>(str)) {
+      *out = val.value();
+    } else {
+      return false;
+    }
+  } else {
+    if (auto val = wpi::parse_integer<To>(str, 10)) {
+      *out = val.value();
+    } else {
+      return false;
+    }
+  }
+  return true;
+}
+
+#define CONVERT(CapsName, LowerName, CType)                                 \
+  static bool Convert##CapsName(Storage::Value* value) {                    \
+    switch (value->type) {                                                  \
+      case Storage::Value::kBool:                                           \
+        value->LowerName##Val = value->boolVal;                             \
+        value->LowerName##Default = value->boolDefault;                     \
+        break;                                                              \
+      case Storage::Value::kDouble:                                         \
+        value->LowerName##Val = value->doubleVal;                           \
+        value->LowerName##Default = value->doubleDefault;                   \
+        break;                                                              \
+      case Storage::Value::kFloat:                                          \
+        value->LowerName##Val = value->floatVal;                            \
+        value->LowerName##Default = value->floatDefault;                    \
+        break;                                                              \
+      case Storage::Value::kInt:                                            \
+        value->LowerName##Val = value->intVal;                              \
+        value->LowerName##Default = value->intDefault;                      \
+        break;                                                              \
+      case Storage::Value::kInt64:                                          \
+        value->LowerName##Val = value->int64Val;                            \
+        value->LowerName##Default = value->int64Default;                    \
+        break;                                                              \
+      case Storage::Value::kString:                                         \
+        if (!ConvertFromString(&value->LowerName##Val, value->stringVal)) { \
+          return false;                                                     \
+        }                                                                   \
+        if (!ConvertFromString(&value->LowerName##Default,                  \
+                               value->stringDefault)) {                     \
+          return false;                                                     \
+        }                                                                   \
+        break;                                                              \
+      default:                                                              \
+        return false;                                                       \
+    }                                                                       \
+    value->type = Storage::Value::k##CapsName;                              \
+    return true;                                                            \
+  }
+
+CONVERT(Int, int, int)
+CONVERT(Int64, int64, int64_t)
+CONVERT(Float, float, float)
+CONVERT(Double, double, double)
+CONVERT(Bool, bool, bool)
+
+static inline bool ConvertString(Storage::Value* value) {
+  return false;
+}
+
+// Arrays can only come from JSON, so we only have to worry about conversions
+// between the various number types, not bool or string
+
+template <typename From, typename To>
+static void ConvertArray(std::vector<To>** outPtr, std::vector<From>** inPtr) {
+  if (*inPtr) {
+    std::vector<To>* tmp;
+    tmp = new std::vector<To>{(*inPtr)->begin(), (*inPtr)->end()};
+    delete *inPtr;
+    *outPtr = tmp;
+  } else {
+    *outPtr = nullptr;
+  }
+}
+
+#define CONVERT_ARRAY(CapsName, LowerName)                           \
+  static bool Convert##CapsName##Array(Storage::Value* value) {      \
+    switch (value->type) {                                           \
+      case Storage::Value::kDoubleArray:                             \
+        ConvertArray(&value->LowerName##Array, &value->doubleArray); \
+        ConvertArray(&value->LowerName##ArrayDefault,                \
+                     &value->doubleArrayDefault);                    \
+        break;                                                       \
+      case Storage::Value::kFloatArray:                              \
+        ConvertArray(&value->LowerName##Array, &value->floatArray);  \
+        ConvertArray(&value->LowerName##ArrayDefault,                \
+                     &value->floatArrayDefault);                     \
+        break;                                                       \
+      case Storage::Value::kIntArray:                                \
+        ConvertArray(&value->LowerName##Array, &value->intArray);    \
+        ConvertArray(&value->LowerName##ArrayDefault,                \
+                     &value->intArrayDefault);                       \
+        break;                                                       \
+      case Storage::Value::kInt64Array:                              \
+        ConvertArray(&value->LowerName##Array, &value->int64Array);  \
+        ConvertArray(&value->LowerName##ArrayDefault,                \
+                     &value->int64ArrayDefault);                     \
+        break;                                                       \
+      default:                                                       \
+        return false;                                                \
+    }                                                                \
+    value->type = Storage::Value::k##CapsName##Array;                \
+    return true;                                                     \
+  }
+
+CONVERT_ARRAY(Int, int)
+CONVERT_ARRAY(Int64, int64)
+CONVERT_ARRAY(Float, float)
+CONVERT_ARRAY(Double, double)
+
+static inline bool ConvertBoolArray(Storage::Value* value) {
+  return false;
+}
+
+static inline bool ConvertStringArray(Storage::Value* value) {
+  return false;
+}
+
+void Storage::Value::Reset(Type newType) {
+  switch (type) {
+    case kChild:
+      delete child;
+      break;
+    case kIntArray:
+      delete intArray;
+      delete intArrayDefault;
+      break;
+    case kInt64Array:
+      delete int64Array;
+      delete int64ArrayDefault;
+      break;
+    case kBoolArray:
+      delete boolArray;
+      delete boolArrayDefault;
+      break;
+    case kFloatArray:
+      delete floatArray;
+      delete floatArrayDefault;
+      break;
+    case kDoubleArray:
+      delete doubleArray;
+      delete doubleArrayDefault;
+      break;
+    case kStringArray:
+      delete stringArray;
+      delete stringArrayDefault;
+      break;
+    case kChildArray:
+      delete childArray;
+      break;
+    default:
+      break;
+  }
+  type = newType;
+}
+
+Storage::Value* Storage::FindValue(std::string_view key) {
+  auto it = m_values.find(key);
+  if (it == m_values.end()) {
+    return nullptr;
+  }
+  return it->second.get();
+}
+
+Storage::Value& Storage::GetValue(std::string_view key) {
+  auto& val = m_values[key];
+  if (!val) {
+    val = std::make_unique<Value>();
+  }
+  return *val;
+}
+
+#define DEFUN(CapsName, LowerName, CType, CParamType, ArrCType)                \
+  CType Storage::Read##CapsName(std::string_view key, CParamType defaultVal)   \
+      const {                                                                  \
+    auto it = m_values.find(key);                                              \
+    if (it == m_values.end()) {                                                \
+      return CType{defaultVal};                                                \
+    }                                                                          \
+    Value& value = *it->second;                                                \
+    if (value.type != Value::k##CapsName) {                                    \
+      if (!Convert##CapsName(&value)) {                                        \
+        value.Reset(Value::k##CapsName);                                       \
+        value.LowerName##Val = defaultVal;                                     \
+        value.LowerName##Default = defaultVal;                                 \
+        value.hasDefault = true;                                               \
+      }                                                                        \
+    }                                                                          \
+    return value.LowerName##Val;                                               \
+  }                                                                            \
+                                                                               \
+  void Storage::Set##CapsName(std::string_view key, CParamType val) {          \
+    auto& valuePtr = m_values[key];                                            \
+    if (!valuePtr) {                                                           \
+      valuePtr = std::make_unique<Value>(Value::k##CapsName);                  \
+    } else {                                                                   \
+      valuePtr->Reset(Value::k##CapsName);                                     \
+    }                                                                          \
+    valuePtr->LowerName##Val = val;                                            \
+    valuePtr->LowerName##Default = {};                                         \
+  }                                                                            \
+                                                                               \
+  CType& Storage::Get##CapsName(std::string_view key, CParamType defaultVal) { \
+    auto& valuePtr = m_values[key];                                            \
+    bool setValue = false;                                                     \
+    if (!valuePtr) {                                                           \
+      valuePtr = std::make_unique<Value>(Value::k##CapsName);                  \
+      setValue = true;                                                         \
+    } else if (valuePtr->type != Value::k##CapsName) {                         \
+      if (!Convert##CapsName(valuePtr.get())) {                                \
+        valuePtr->Reset(Value::k##CapsName);                                   \
+        setValue = true;                                                       \
+      }                                                                        \
+    }                                                                          \
+    if (setValue) {                                                            \
+      valuePtr->LowerName##Val = defaultVal;                                   \
+    }                                                                          \
+    if (!valuePtr->hasDefault) {                                               \
+      valuePtr->LowerName##Default = defaultVal;                               \
+      valuePtr->hasDefault = true;                                             \
+    }                                                                          \
+    return valuePtr->LowerName##Val;                                           \
+  }                                                                            \
+                                                                               \
+  std::vector<ArrCType>& Storage::Get##CapsName##Array(                        \
+      std::string_view key, wpi::span<const ArrCType> defaultVal) {            \
+    auto& valuePtr = m_values[key];                                            \
+    bool setValue = false;                                                     \
+    if (!valuePtr) {                                                           \
+      valuePtr = std::make_unique<Value>(Value::k##CapsName##Array);           \
+      setValue = true;                                                         \
+    } else if (valuePtr->type != Value::k##CapsName##Array) {                  \
+      if (!Convert##CapsName##Array(valuePtr.get())) {                         \
+        valuePtr->Reset(Value::k##CapsName##Array);                            \
+        setValue = true;                                                       \
+      }                                                                        \
+    }                                                                          \
+    if (setValue) {                                                            \
+      valuePtr->LowerName##Array =                                             \
+          new std::vector<ArrCType>{defaultVal.begin(), defaultVal.end()};     \
+    }                                                                          \
+    if (!valuePtr->hasDefault) {                                               \
+      if (defaultVal.empty()) {                                                \
+        valuePtr->LowerName##ArrayDefault = nullptr;                           \
+      } else {                                                                 \
+        valuePtr->LowerName##ArrayDefault =                                    \
+            new std::vector<ArrCType>{defaultVal.begin(), defaultVal.end()};   \
+      }                                                                        \
+      valuePtr->hasDefault = true;                                             \
+    }                                                                          \
+    assert(valuePtr->LowerName##Array);                                        \
+    return *valuePtr->LowerName##Array;                                        \
+  }
+
+DEFUN(Int, int, int, int, int)
+DEFUN(Int64, int64, int64_t, int64_t, int64_t)
+DEFUN(Bool, bool, bool, bool, int)
+DEFUN(Float, float, float, float, float)
+DEFUN(Double, double, double, double, double)
+DEFUN(String, string, std::string, std::string_view, std::string)
+
+Storage& Storage::GetChild(std::string_view label_id) {
+  auto [label, id] = wpi::split(label_id, "###");
+  if (id.empty()) {
+    id = label;
+  }
+  auto& childPtr = m_values[id];
+  if (!childPtr) {
+    childPtr = std::make_unique<Value>();
+  }
+  if (childPtr->type != Value::kChild) {
+    childPtr->type = Value::kChild;
+    childPtr->child = new Storage;
+  }
+  return *childPtr->child;
+}
+
+std::vector<std::unique_ptr<Storage>>& Storage::GetChildArray(
+    std::string_view key) {
+  auto& valuePtr = m_values[key];
+  if (!valuePtr) {
+    valuePtr = std::make_unique<Value>(Value::kChildArray);
+    valuePtr->childArray = new std::vector<std::unique_ptr<Storage>>();
+  } else if (valuePtr->type != Value::kChildArray) {
+    valuePtr->Reset(Value::kChildArray);
+    valuePtr->childArray = new std::vector<std::unique_ptr<Storage>>();
+  }
+
+  return *valuePtr->childArray;
+}
+
+std::unique_ptr<Storage::Value> Storage::Erase(std::string_view key) {
+  auto it = m_values.find(key);
+  if (it != m_values.end()) {
+    auto rv = std::move(it->getValue());
+    m_values.erase(it);
+    return rv;
+  }
+  return nullptr;
+}
+
+void Storage::EraseChildren() {
+  for (auto&& kv : m_values) {
+    if (kv.getValue()->type == Value::kChild) {
+      m_values.remove(&kv);
+    }
+  }
+}
+
+static bool JsonArrayToStorage(Storage::Value* valuePtr, const wpi::json& jarr,
+                               const char* filename) {
+  auto& arr = jarr.get_ref<const wpi::json::array_t&>();
+  if (arr.empty()) {
+    ImGui::LogText("empty array in %s, ignoring", filename);
+    return false;
+  }
+
+  // guess array type from first element
+  switch (arr[0].type()) {
+    case wpi::json::value_t::boolean:
+      if (valuePtr->type != Storage::Value::kBoolArray) {
+        valuePtr->Reset(Storage::Value::kBoolArray);
+        valuePtr->boolArray = new std::vector<int>();
+        valuePtr->boolArrayDefault = nullptr;
+      }
+      break;
+    case wpi::json::value_t::number_float:
+      if (valuePtr->type != Storage::Value::kDoubleArray) {
+        valuePtr->Reset(Storage::Value::kDoubleArray);
+        valuePtr->doubleArray = new std::vector<double>();
+        valuePtr->doubleArrayDefault = nullptr;
+      }
+      break;
+    case wpi::json::value_t::number_integer:
+    case wpi::json::value_t::number_unsigned:
+      if (valuePtr->type != Storage::Value::kInt64Array) {
+        valuePtr->Reset(Storage::Value::kInt64Array);
+        valuePtr->int64Array = new std::vector<int64_t>();
+        valuePtr->int64ArrayDefault = nullptr;
+      }
+      break;
+    case wpi::json::value_t::string:
+      if (valuePtr->type != Storage::Value::kStringArray) {
+        valuePtr->Reset(Storage::Value::kStringArray);
+        valuePtr->stringArray = new std::vector<std::string>();
+        valuePtr->stringArrayDefault = nullptr;
+      }
+      break;
+    case wpi::json::value_t::object:
+      if (valuePtr->type != Storage::Value::kChildArray) {
+        valuePtr->Reset(Storage::Value::kChildArray);
+        valuePtr->childArray = new std::vector<std::unique_ptr<Storage>>();
+      }
+      break;
+    case wpi::json::value_t::array:
+      ImGui::LogText("nested array in %s, ignoring", filename);
+      return false;
+    default:
+      ImGui::LogText("null value in %s, ignoring", filename);
+      return false;
+  }
+
+  // loop over array to store elements
+  for (auto jvalue : arr) {
+    switch (jvalue.type()) {
+      case wpi::json::value_t::boolean:
+        if (valuePtr->type == Storage::Value::kBoolArray) {
+          valuePtr->boolArray->push_back(jvalue.get<bool>());
+        } else {
+          goto error;
+        }
+        break;
+      case wpi::json::value_t::number_float:
+        if (valuePtr->type == Storage::Value::kDoubleArray) {
+          valuePtr->doubleArray->push_back(jvalue.get<double>());
+        } else {
+          goto error;
+        }
+        break;
+      case wpi::json::value_t::number_integer:
+        if (valuePtr->type == Storage::Value::kInt64Array) {
+          valuePtr->int64Array->push_back(jvalue.get<int64_t>());
+        } else if (valuePtr->type == Storage::Value::kDoubleArray) {
+          valuePtr->doubleArray->push_back(jvalue.get<int64_t>());
+        } else {
+          goto error;
+        }
+        break;
+      case wpi::json::value_t::number_unsigned:
+        if (valuePtr->type == Storage::Value::kInt64Array) {
+          valuePtr->int64Array->push_back(jvalue.get<uint64_t>());
+        } else if (valuePtr->type == Storage::Value::kDoubleArray) {
+          valuePtr->doubleArray->push_back(jvalue.get<uint64_t>());
+        } else {
+          goto error;
+        }
+        break;
+      case wpi::json::value_t::string:
+        if (valuePtr->type == Storage::Value::kStringArray) {
+          valuePtr->stringArray->emplace_back(
+              jvalue.get_ref<const std::string&>());
+        } else {
+          goto error;
+        }
+        break;
+      case wpi::json::value_t::object:
+        if (valuePtr->type == Storage::Value::kChildArray) {
+          valuePtr->childArray->emplace_back(std::make_unique<Storage>());
+          valuePtr->childArray->back()->FromJson(jvalue, filename);
+        } else {
+          goto error;
+        }
+        break;
+      case wpi::json::value_t::array:
+        ImGui::LogText("nested array in %s, ignoring", filename);
+        return false;
+      default:
+        ImGui::LogText("null value in %s, ignoring", filename);
+        return false;
+    }
+  }
+  return true;
+
+error:
+  ImGui::LogText("array with variant types in %s, ignoring", filename);
+  return false;
+}
+
+bool Storage::FromJson(const wpi::json& json, const char* filename) {
+  if (m_fromJson) {
+    return m_fromJson(json, filename);
+  }
+
+  if (!json.is_object()) {
+    ImGui::LogText("non-object in %s", filename);
+    return false;
+  }
+  for (auto&& jkv : json.items()) {
+    auto& valuePtr = m_values[jkv.key()];
+    bool created = false;
+    if (!valuePtr) {
+      valuePtr = std::make_unique<Value>();
+      created = true;
+    }
+    auto& jvalue = jkv.value();
+    switch (jvalue.type()) {
+      case wpi::json::value_t::boolean:
+        valuePtr->Reset(Value::kBool);
+        valuePtr->boolVal = jvalue.get<bool>();
+        break;
+      case wpi::json::value_t::number_float:
+        valuePtr->Reset(Value::kDouble);
+        valuePtr->doubleVal = jvalue.get<double>();
+        break;
+      case wpi::json::value_t::number_integer:
+        valuePtr->Reset(Value::kInt64);
+        valuePtr->int64Val = jvalue.get<int64_t>();
+        break;
+      case wpi::json::value_t::number_unsigned:
+        valuePtr->Reset(Value::kInt64);
+        valuePtr->int64Val = jvalue.get<uint64_t>();
+        break;
+      case wpi::json::value_t::string:
+        valuePtr->Reset(Value::kString);
+        valuePtr->stringVal = jvalue.get_ref<const std::string&>();
+        break;
+      case wpi::json::value_t::object:
+        if (valuePtr->type != Value::kChild) {
+          valuePtr->Reset(Value::kChild);
+          valuePtr->child = new Storage;
+        }
+        valuePtr->child->FromJson(jvalue, filename);  // recurse
+        break;
+      case wpi::json::value_t::array:
+        if (!JsonArrayToStorage(valuePtr.get(), jvalue, filename)) {
+          if (created) {
+            m_values.erase(jkv.key());
+          }
+        }
+        break;
+      default:
+        ImGui::LogText("null value in %s, ignoring", filename);
+        if (created) {
+          m_values.erase(jkv.key());
+        }
+        break;
+    }
+  }
+  return true;
+}
+
+template <typename T>
+static wpi::json StorageToJsonArray(const std::vector<T>& arr) {
+  wpi::json jarr = wpi::json::array();
+  for (auto&& v : arr) {
+    jarr.emplace_back(v);
+  }
+  return jarr;
+}
+
+template <>
+wpi::json StorageToJsonArray<std::unique_ptr<Storage>>(
+    const std::vector<std::unique_ptr<Storage>>& arr) {
+  wpi::json jarr = wpi::json::array();
+  for (auto&& v : arr) {
+    jarr.emplace_back(v->ToJson());
+  }
+  // remove any trailing empty items
+  while (!jarr.empty() && jarr.back().empty()) {
+    jarr.get_ref<wpi::json::array_t&>().pop_back();
+  }
+  return jarr;
+}
+
+wpi::json Storage::ToJson() const {
+  if (m_toJson) {
+    return m_toJson();
+  }
+
+  wpi::json j = wpi::json::object();
+  for (auto&& kv : m_values) {
+    wpi::json jelem;
+    auto& value = *kv.getValue();
+    switch (value.type) {
+#define CASE(CapsName, LowerName)                                        \
+  case Value::k##CapsName:                                               \
+    if (value.hasDefault &&                                              \
+        value.LowerName##Val == value.LowerName##Default) {              \
+      continue;                                                          \
+    }                                                                    \
+    jelem = value.LowerName##Val;                                        \
+    break;                                                               \
+  case Value::k##CapsName##Array:                                        \
+    if (value.hasDefault &&                                              \
+        ((!value.LowerName##ArrayDefault &&                              \
+          value.LowerName##Array->empty()) ||                            \
+         (value.LowerName##ArrayDefault &&                               \
+          *value.LowerName##Array == *value.LowerName##ArrayDefault))) { \
+      continue;                                                          \
+    }                                                                    \
+    jelem = StorageToJsonArray(*value.LowerName##Array);                 \
+    break;
+
+      CASE(Int, int)
+      CASE(Int64, int64)
+      CASE(Bool, bool)
+      CASE(Float, float)
+      CASE(Double, double)
+      CASE(String, string)
+
+      case Value::kChild:
+        jelem = value.child->ToJson();  // recurse
+        if (jelem.empty()) {
+          continue;
+        }
+        break;
+      case Value::kChildArray:
+        jelem = StorageToJsonArray(*value.childArray);
+        if (jelem.empty()) {
+          continue;
+        }
+        break;
+      default:
+        continue;
+    }
+    j.emplace(kv.getKey(), std::move(jelem));
+  }
+  return j;
+}
+
+void Storage::Clear() {
+  if (m_clear) {
+    return m_clear();
+  }
+
+  ClearValues();
+}
+
+void Storage::ClearValues() {
+  for (auto&& kv : m_values) {
+    auto& value = *kv.getValue();
+    switch (value.type) {
+      case Value::kInt:
+        value.intVal = value.intDefault;
+        break;
+      case Value::kInt64:
+        value.int64Val = value.int64Default;
+        break;
+      case Value::kBool:
+        value.boolVal = value.boolDefault;
+        break;
+      case Value::kFloat:
+        value.floatVal = value.floatDefault;
+        break;
+      case Value::kDouble:
+        value.doubleVal = value.doubleDefault;
+        break;
+      case Value::kString:
+        value.stringVal = value.stringDefault;
+        break;
+      case Value::kIntArray:
+        *value.intArray = *value.intArrayDefault;
+        break;
+      case Value::kInt64Array:
+        *value.int64Array = *value.int64ArrayDefault;
+        break;
+      case Value::kBoolArray:
+        *value.boolArray = *value.boolArrayDefault;
+        break;
+      case Value::kFloatArray:
+        *value.floatArray = *value.floatArrayDefault;
+        break;
+      case Value::kDoubleArray:
+        *value.doubleArray = *value.doubleArrayDefault;
+        break;
+      case Value::kStringArray:
+        *value.stringArray = *value.stringArrayDefault;
+        break;
+      case Value::kChild:
+        value.child->Clear();
+        break;
+      case Value::kChildArray:
+        for (auto&& child : *value.childArray) {
+          child->Clear();
+        }
+        break;
+      default:
+        break;
+    }
+  }
+}
+
+void Storage::Apply() {
+  if (m_apply) {
+    return m_apply();
+  }
+
+  ApplyChildren();
+}
+
+void Storage::ApplyChildren() {
+  for (auto&& kv : m_values) {
+    auto& value = *kv.getValue();
+    switch (value.type) {
+      case Value::kChild:
+        value.child->Apply();
+        break;
+      case Value::kChildArray:
+        for (auto&& child : *value.childArray) {
+          child->Apply();
+        }
+        break;
+      default:
+        break;
+    }
+  }
+}
diff --git a/glass/src/lib/native/cpp/Window.cpp b/glass/src/lib/native/cpp/Window.cpp
index 5c014eb..6296fab 100644
--- a/glass/src/lib/native/cpp/Window.cpp
+++ b/glass/src/lib/native/cpp/Window.cpp
@@ -8,23 +8,28 @@
 #include <wpi/StringExtras.h>
 
 #include "glass/Context.h"
+#include "glass/Storage.h"
 
 using namespace glass;
 
+Window::Window(Storage& storage, std::string_view id,
+               Visibility defaultVisibility)
+    : m_id{id},
+      m_name{storage.GetString("name")},
+      m_defaultName{id},
+      m_visible{storage.GetBool("visible", defaultVisibility != kHide)},
+      m_enabled{storage.GetBool("enabled", defaultVisibility != kDisabled)},
+      m_defaultVisible{storage.GetValue("visible").boolDefault},
+      m_defaultEnabled{storage.GetValue("enabled").boolDefault} {}
+
 void Window::SetVisibility(Visibility visibility) {
-  switch (visibility) {
-    case kHide:
-      m_visible = false;
-      m_enabled = true;
-      break;
-    case kShow:
-      m_visible = true;
-      m_enabled = true;
-      break;
-    case kDisabled:
-      m_enabled = false;
-      break;
-  }
+  m_visible = visibility != kHide;
+  m_enabled = visibility != kDisabled;
+}
+
+void Window::SetDefaultVisibility(Visibility visibility) {
+  m_defaultVisible = visibility != kHide;
+  m_defaultEnabled = visibility != kDisabled;
 }
 
 void Window::Display() {
@@ -85,27 +90,3 @@
     m_size.y *= scale;
   }
 }
-
-void Window::IniReadLine(const char* line) {
-  auto [name, value] = wpi::split(line, '=');
-  name = wpi::trim(name);
-  value = wpi::trim(value);
-
-  if (name == "name") {
-    m_name = value;
-  } else if (name == "visible") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_visible = num.value();
-    }
-  } else if (name == "enabled") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_enabled = num.value();
-    }
-  }
-}
-
-void Window::IniWriteAll(const char* typeName, ImGuiTextBuffer* out_buf) {
-  out_buf->appendf("[%s][%s]\nname=%s\nvisible=%d\nenabled=%d\n\n", typeName,
-                   m_id.c_str(), m_name.c_str(), m_visible ? 1 : 0,
-                   m_enabled ? 1 : 0);
-}
diff --git a/glass/src/lib/native/cpp/WindowManager.cpp b/glass/src/lib/native/cpp/WindowManager.cpp
index 037b9bd..333dad4 100644
--- a/glass/src/lib/native/cpp/WindowManager.cpp
+++ b/glass/src/lib/native/cpp/WindowManager.cpp
@@ -10,30 +10,23 @@
 #include <fmt/format.h>
 #include <wpigui.h>
 
+#include "glass/Context.h"
+#include "glass/Storage.h"
+
 using namespace glass;
 
-WindowManager::WindowManager(std::string_view iniName)
-    : m_iniSaver{iniName, this} {}
-
-// read/write open state to ini file
-void* WindowManager::IniSaver::IniReadOpen(const char* name) {
-  return m_manager->GetOrAddWindow(name, true);
-}
-
-void WindowManager::IniSaver::IniReadLine(void* entry, const char* lineStr) {
-  static_cast<Window*>(entry)->IniReadLine(lineStr);
-}
-
-void WindowManager::IniSaver::IniWriteAll(ImGuiTextBuffer* out_buf) {
-  const char* typeName = GetTypeName();
-  for (auto&& window : m_manager->m_windows) {
-    window->IniWriteAll(typeName, out_buf);
-  }
+WindowManager::WindowManager(Storage& storage) : m_storage{storage} {
+  storage.SetCustomApply([this] {
+    for (auto&& childIt : m_storage.GetChildren()) {
+      GetOrAddWindow(childIt.key(), true);
+    }
+  });
 }
 
 Window* WindowManager::AddWindow(std::string_view id,
-                                 wpi::unique_function<void()> display) {
-  auto win = GetOrAddWindow(id, false);
+                                 wpi::unique_function<void()> display,
+                                 Window::Visibility defaultVisibility) {
+  auto win = GetOrAddWindow(id, false, defaultVisibility);
   if (!win) {
     return nullptr;
   }
@@ -46,8 +39,9 @@
 }
 
 Window* WindowManager::AddWindow(std::string_view id,
-                                 std::unique_ptr<View> view) {
-  auto win = GetOrAddWindow(id, false);
+                                 std::unique_ptr<View> view,
+                                 Window::Visibility defaultVisibility) {
+  auto win = GetOrAddWindow(id, false, defaultVisibility);
   if (!win) {
     return nullptr;
   }
@@ -59,7 +53,8 @@
   return win;
 }
 
-Window* WindowManager::GetOrAddWindow(std::string_view id, bool duplicateOk) {
+Window* WindowManager::GetOrAddWindow(std::string_view id, bool duplicateOk,
+                                      Window::Visibility defaultVisibility) {
   // binary search
   auto it = std::lower_bound(
       m_windows.begin(), m_windows.end(), id,
@@ -72,7 +67,11 @@
     return it->get();
   }
   // insert before (keeps sort)
-  return m_windows.emplace(it, std::make_unique<Window>(id))->get();
+  return m_windows
+      .emplace(it, std::make_unique<Window>(
+                       m_storage.GetChild(id).GetChild("window"), id,
+                       defaultVisibility))
+      ->get();
 }
 
 Window* WindowManager::GetWindow(std::string_view id) {
@@ -86,8 +85,12 @@
   return it->get();
 }
 
+void WindowManager::RemoveWindow(size_t index) {
+  m_storage.Erase(m_windows[index]->GetId());
+  m_windows.erase(m_windows.begin() + index);
+}
+
 void WindowManager::GlobalInit() {
-  wpi::gui::AddInit([this] { m_iniSaver.Initialize(); });
   wpi::gui::AddWindowScaler([this](float scale) {
     // scale default window positions
     for (auto&& window : m_windows) {
@@ -104,7 +107,9 @@
 }
 
 void WindowManager::DisplayWindows() {
+  PushStorageStack(m_storage);
   for (auto&& window : m_windows) {
     window->Display();
   }
+  PopStorageStack();
 }
diff --git a/glass/src/lib/native/cpp/hardware/AnalogInput.cpp b/glass/src/lib/native/cpp/hardware/AnalogInput.cpp
index b9699a4..af22511 100644
--- a/glass/src/lib/native/cpp/hardware/AnalogInput.cpp
+++ b/glass/src/lib/native/cpp/hardware/AnalogInput.cpp
@@ -8,6 +8,7 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
 
 using namespace glass;
 
@@ -18,10 +19,10 @@
   }
 
   // build label
-  std::string* name = GetStorage().GetStringRef("name");
+  std::string& name = GetStorage().GetString("name");
   char label[128];
-  if (!name->empty()) {
-    std::snprintf(label, sizeof(label), "%s [%d]###name", name->c_str(), index);
+  if (!name.empty()) {
+    std::snprintf(label, sizeof(label), "%s [%d]###name", name.c_str(), index);
   } else {
     std::snprintf(label, sizeof(label), "In[%d]###name", index);
   }
@@ -42,8 +43,8 @@
   }
 
   // context menu to change name
-  if (PopupEditName("name", name)) {
-    voltageData->SetName(name->c_str());
+  if (PopupEditName("name", &name)) {
+    voltageData->SetName(name);
   }
 }
 
diff --git a/glass/src/lib/native/cpp/hardware/AnalogOutput.cpp b/glass/src/lib/native/cpp/hardware/AnalogOutput.cpp
index 3a9594b..174e013 100644
--- a/glass/src/lib/native/cpp/hardware/AnalogOutput.cpp
+++ b/glass/src/lib/native/cpp/hardware/AnalogOutput.cpp
@@ -6,6 +6,7 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
 #include "glass/other/DeviceTree.h"
 
 using namespace glass;
@@ -26,10 +27,10 @@
       PushID(i);
 
       // build label
-      std::string* name = GetStorage().GetStringRef("name");
+      std::string& name = GetStorage().GetString("name");
       char label[128];
-      if (!name->empty()) {
-        std::snprintf(label, sizeof(label), "%s [%d]###name", name->c_str(), i);
+      if (!name.empty()) {
+        std::snprintf(label, sizeof(label), "%s [%d]###name", name.c_str(), i);
       } else {
         std::snprintf(label, sizeof(label), "Out[%d]###name", i);
       }
@@ -37,9 +38,9 @@
       double value = analogOutData->GetValue();
       DeviceDouble(label, true, &value, analogOutData);
 
-      if (PopupEditName("name", name)) {
+      if (PopupEditName("name", &name)) {
         if (analogOutData) {
-          analogOutData->SetName(name->c_str());
+          analogOutData->SetName(name);
         }
       }
       PopID();
diff --git a/glass/src/lib/native/cpp/hardware/DIO.cpp b/glass/src/lib/native/cpp/hardware/DIO.cpp
index 59d71f8..f52974a 100644
--- a/glass/src/lib/native/cpp/hardware/DIO.cpp
+++ b/glass/src/lib/native/cpp/hardware/DIO.cpp
@@ -8,7 +8,7 @@
 
 #include "glass/DataSource.h"
 #include "glass/hardware/Encoder.h"
-#include "glass/support/IniSaverInfo.h"
+#include "glass/support/NameSetting.h"
 
 using namespace glass;
 
@@ -28,17 +28,18 @@
   auto dutyCycleData = dutyCycle ? dutyCycle->GetValueData() : nullptr;
 
   bool exists = model->Exists();
-  auto& info = dioData->GetNameInfo();
+  NameSetting dioName{dioData->GetName()};
   char label[128];
   if (exists && dpwmData) {
-    dpwmData->GetNameInfo().GetLabel(label, sizeof(label), "PWM", index);
+    NameSetting{dpwmData->GetName()}.GetLabel(label, sizeof(label), "PWM",
+                                              index);
     if (auto simDevice = dpwm->GetSimDevice()) {
       LabelSimDevice(label, simDevice);
     } else {
       dpwmData->LabelText(label, "%0.3f", dpwmData->GetValue());
     }
   } else if (exists && encoder) {
-    info.GetLabel(label, sizeof(label), " In", index);
+    dioName.GetLabel(label, sizeof(label), " In", index);
     if (auto simDevice = encoder->GetSimDevice()) {
       LabelSimDevice(label, simDevice);
     } else {
@@ -48,7 +49,8 @@
       ImGui::PopStyleColor();
     }
   } else if (exists && dutyCycleData) {
-    dutyCycleData->GetNameInfo().GetLabel(label, sizeof(label), "Dty", index);
+    NameSetting{dutyCycleData->GetName()}.GetLabel(label, sizeof(label), "Dty",
+                                                   index);
     if (auto simDevice = dutyCycle->GetSimDevice()) {
       LabelSimDevice(label, simDevice);
     } else {
@@ -60,10 +62,10 @@
   } else {
     const char* name = model->GetName();
     if (name[0] != '\0') {
-      info.GetLabel(label, sizeof(label), name);
+      dioName.GetLabel(label, sizeof(label), name);
     } else {
-      info.GetLabel(label, sizeof(label), model->IsInput() ? " In" : "Out",
-                    index);
+      dioName.GetLabel(label, sizeof(label), model->IsInput() ? " In" : "Out",
+                       index);
     }
     if (auto simDevice = model->GetSimDevice()) {
       LabelSimDevice(label, simDevice);
@@ -87,12 +89,12 @@
       }
     }
   }
-  if (info.PopupEditName(index)) {
+  if (dioName.PopupEditName(index)) {
     if (dpwmData) {
-      dpwmData->SetName(info.GetName());
+      dpwmData->SetName(dioName.GetName());
     }
     if (dutyCycleData) {
-      dutyCycleData->SetName(info.GetName());
+      dutyCycleData->SetName(dioName.GetName());
     }
   }
 }
diff --git a/glass/src/lib/native/cpp/hardware/Encoder.cpp b/glass/src/lib/native/cpp/hardware/Encoder.cpp
index 599a5b8..7032636 100644
--- a/glass/src/lib/native/cpp/hardware/Encoder.cpp
+++ b/glass/src/lib/native/cpp/hardware/Encoder.cpp
@@ -9,6 +9,7 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
 
 using namespace glass;
 
@@ -66,21 +67,21 @@
   int chB = model->GetChannelB();
 
   // build header label
-  std::string* name = GetStorage().GetStringRef("name");
+  std::string& name = GetStorage().GetString("name");
   char label[128];
-  if (!name->empty()) {
-    std::snprintf(label, sizeof(label), "%s [%d,%d]###name", name->c_str(), chA,
-                  chB);
+  if (!name.empty()) {
+    std::snprintf(label, sizeof(label), "%s [%d,%d]###header", name.c_str(),
+                  chA, chB);
   } else {
-    std::snprintf(label, sizeof(label), "Encoder[%d,%d]###name", chA, chB);
+    std::snprintf(label, sizeof(label), "Encoder[%d,%d]###header", chA, chB);
   }
 
   // header
   bool open = CollapsingHeader(label);
 
   // context menu to change name
-  if (PopupEditName("name", name)) {
-    model->SetName(name->c_str());
+  if (PopupEditName("header", &name)) {
+    model->SetName(name);
   }
 
   if (!open) {
diff --git a/glass/src/lib/native/cpp/hardware/LEDDisplay.cpp b/glass/src/lib/native/cpp/hardware/LEDDisplay.cpp
index c1ece3a..c3c2406 100644
--- a/glass/src/lib/native/cpp/hardware/LEDDisplay.cpp
+++ b/glass/src/lib/native/cpp/hardware/LEDDisplay.cpp
@@ -7,6 +7,7 @@
 #include <wpi/SmallVector.h>
 
 #include "glass/Context.h"
+#include "glass/Storage.h"
 #include "glass/support/ExtraGuiWidgets.h"
 
 using namespace glass;
@@ -25,27 +26,27 @@
   bool running = model->IsRunning();
   auto& storage = GetStorage();
 
-  int* numColumns = storage.GetIntRef("columns", 10);
-  bool* serpentine = storage.GetBoolRef("serpentine", false);
-  int* order = storage.GetIntRef("order", LEDConfig::RowMajor);
-  int* start = storage.GetIntRef("start", LEDConfig::UpperLeft);
+  int& numColumns = storage.GetInt("columns", 10);
+  bool& serpentine = storage.GetBool("serpentine", false);
+  int& order = storage.GetInt("order", LEDConfig::RowMajor);
+  int& start = storage.GetInt("start", LEDConfig::UpperLeft);
 
   ImGui::PushItemWidth(ImGui::GetFontSize() * 6);
   ImGui::LabelText("Length", "%d", length);
   ImGui::LabelText("Running", "%s", running ? "Yes" : "No");
-  ImGui::InputInt("Columns", numColumns);
+  ImGui::InputInt("Columns", &numColumns);
   {
     static const char* options[] = {"Row Major", "Column Major"};
-    ImGui::Combo("Order", order, options, 2);
+    ImGui::Combo("Order", &order, options, 2);
   }
   {
     static const char* options[] = {"Upper Left", "Lower Left", "Upper Right",
                                     "Lower Right"};
-    ImGui::Combo("Start", start, options, 4);
+    ImGui::Combo("Start", &start, options, 4);
   }
-  ImGui::Checkbox("Serpentine", serpentine);
-  if (*numColumns < 1) {
-    *numColumns = 1;
+  ImGui::Checkbox("Serpentine", &serpentine);
+  if (numColumns < 1) {
+    numColumns = 1;
   }
   ImGui::PopItemWidth();
 
@@ -74,12 +75,12 @@
   }
 
   LEDConfig config;
-  config.serpentine = *serpentine;
-  config.order = static_cast<LEDConfig::Order>(*order);
-  config.start = static_cast<LEDConfig::Start>(*start);
+  config.serpentine = serpentine;
+  config.order = static_cast<LEDConfig::Order>(order);
+  config.start = static_cast<LEDConfig::Start>(start);
 
-  DrawLEDs(iData->values.data(), length, *numColumns, iData->colors.data(), 0,
-           0, config);
+  DrawLEDs(iData->values.data(), length, numColumns, iData->colors.data(), 0, 0,
+           config);
 }
 
 void glass::DisplayLEDDisplays(LEDDisplaysModel* model) {
diff --git a/glass/src/lib/native/cpp/hardware/PCM.cpp b/glass/src/lib/native/cpp/hardware/PCM.cpp
index 23746be..d260bda 100644
--- a/glass/src/lib/native/cpp/hardware/PCM.cpp
+++ b/glass/src/lib/native/cpp/hardware/PCM.cpp
@@ -12,9 +12,10 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
 #include "glass/other/DeviceTree.h"
 #include "glass/support/ExtraGuiWidgets.h"
-#include "glass/support/IniSaverInfo.h"
+#include "glass/support/NameSetting.h"
 
 using namespace glass;
 
@@ -42,18 +43,19 @@
   }
 
   // build header label
-  std::string* name = GetStorage().GetStringRef("name");
+  std::string& name = GetStorage().GetString("name");
   char label[128];
-  if (!name->empty()) {
-    std::snprintf(label, sizeof(label), "%s [%d]###name", name->c_str(), index);
+  if (!name.empty()) {
+    std::snprintf(label, sizeof(label), "%s [%d]###header", name.c_str(),
+                  index);
   } else {
-    std::snprintf(label, sizeof(label), "PCM[%d]###name", index);
+    std::snprintf(label, sizeof(label), "PCM[%d]###header", index);
   }
 
   // header
   bool open = CollapsingHeader(label);
 
-  PopupEditName("name", name);
+  PopupEditName("header", &name);
 
   ImGui::SetItemAllowOverlap();
   ImGui::SameLine();
@@ -68,11 +70,11 @@
     model->ForEachSolenoid([&](SolenoidModel& solenoid, int j) {
       if (auto data = solenoid.GetOutputData()) {
         PushID(j);
-        char solenoidName[64];
-        auto& info = data->GetNameInfo();
-        info.GetLabel(solenoidName, sizeof(solenoidName), "Solenoid", j);
-        data->LabelText(solenoidName, "%s", channels[j] == 1 ? "On" : "Off");
-        info.PopupEditName(j);
+        char label[64];
+        NameSetting name{data->GetName()};
+        name.GetLabel(label, sizeof(label), "Solenoid", j);
+        data->LabelText(label, "%s", channels[j] == 1 ? "On" : "Off");
+        name.PopupEditName(j);
         PopID();
       }
     });
diff --git a/glass/src/lib/native/cpp/hardware/PWM.cpp b/glass/src/lib/native/cpp/hardware/PWM.cpp
index 3ff8e52..0200ac6 100644
--- a/glass/src/lib/native/cpp/hardware/PWM.cpp
+++ b/glass/src/lib/native/cpp/hardware/PWM.cpp
@@ -8,6 +8,7 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
 
 using namespace glass;
 
@@ -18,10 +19,10 @@
   }
 
   // build label
-  std::string* name = GetStorage().GetStringRef("name");
+  std::string& name = GetStorage().GetString("name");
   char label[128];
-  if (!name->empty()) {
-    std::snprintf(label, sizeof(label), "%s [%d]###name", name->c_str(), index);
+  if (!name.empty()) {
+    std::snprintf(label, sizeof(label), "%s [%d]###name", name.c_str(), index);
   } else {
     std::snprintf(label, sizeof(label), "PWM[%d]###name", index);
   }
@@ -35,8 +36,8 @@
     float val = outputsEnabled ? data->GetValue() : 0;
     data->LabelText(label, "%0.3f", val);
   }
-  if (PopupEditName("name", name)) {
-    data->SetName(name->c_str());
+  if (PopupEditName("name", &name)) {
+    data->SetName(name);
   }
 }
 
diff --git a/glass/src/lib/native/cpp/hardware/PowerDistribution.cpp b/glass/src/lib/native/cpp/hardware/PowerDistribution.cpp
index 46e550b..f1de461 100644
--- a/glass/src/lib/native/cpp/hardware/PowerDistribution.cpp
+++ b/glass/src/lib/native/cpp/hardware/PowerDistribution.cpp
@@ -11,7 +11,7 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
-#include "glass/support/IniSaverInfo.h"
+#include "glass/support/NameSetting.h"
 
 using namespace glass;
 
@@ -19,16 +19,16 @@
   float width = 0;
   if (auto currentData = pdp.GetCurrentData(channel)) {
     ImGui::PushID(channel);
-    auto& leftInfo = currentData->GetNameInfo();
+    NameSetting leftName{currentData->GetName()};
     char name[64];
-    leftInfo.GetLabel(name, sizeof(name), "", channel);
+    leftName.GetLabel(name, sizeof(name), "", channel);
     double val = currentData->GetValue();
     ImGui::SetNextItemWidth(ImGui::GetFontSize() * 4);
     if (currentData->InputDouble(name, &val, 0, 0, "%.3f")) {
       pdp.SetCurrent(channel, val);
     }
     width = ImGui::GetItemRectSize().x;
-    leftInfo.PopupEditName(channel);
+    leftName.PopupEditName(channel);
     ImGui::PopID();
   }
   return width;
diff --git a/glass/src/lib/native/cpp/hardware/Relay.cpp b/glass/src/lib/native/cpp/hardware/Relay.cpp
index 59bbc51..e071de7 100644
--- a/glass/src/lib/native/cpp/hardware/Relay.cpp
+++ b/glass/src/lib/native/cpp/hardware/Relay.cpp
@@ -8,6 +8,7 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
 #include "glass/support/ExtraGuiWidgets.h"
 
 using namespace glass;
@@ -31,20 +32,20 @@
     }
   }
 
-  std::string* name = GetStorage().GetStringRef("name");
+  std::string& name = GetStorage().GetString("name");
   ImGui::PushID("name");
-  if (!name->empty()) {
-    ImGui::Text("%s [%d]", name->c_str(), index);
+  if (!name.empty()) {
+    ImGui::Text("%s [%d]", name.c_str(), index);
   } else {
     ImGui::Text("Relay[%d]", index);
   }
   ImGui::PopID();
-  if (PopupEditName("name", name)) {
+  if (PopupEditName("name", &name)) {
     if (forwardData) {
-      forwardData->SetName(name->c_str());
+      forwardData->SetName(name);
     }
     if (reverseData) {
-      reverseData->SetName(name->c_str());
+      reverseData->SetName(name);
     }
   }
   ImGui::SameLine();
diff --git a/glass/src/lib/native/cpp/other/DeviceTree.cpp b/glass/src/lib/native/cpp/other/DeviceTree.cpp
index cd69eb7..cfce8c4 100644
--- a/glass/src/lib/native/cpp/other/DeviceTree.cpp
+++ b/glass/src/lib/native/cpp/other/DeviceTree.cpp
@@ -51,13 +51,13 @@
   PushID(id);
 
   // build label
-  std::string* name = GetStorage().GetStringRef("name");
+  std::string& name = GetStorage().GetString("name");
   char label[128];
-  std::snprintf(label, sizeof(label), "%s###name",
-                name->empty() ? id : name->c_str());
+  std::snprintf(label, sizeof(label), "%s###header",
+                name.empty() ? id : name.c_str());
 
   bool open = CollapsingHeader(label, flags);
-  PopupEditName("name", name);
+  PopupEditName("header", &name);
 
   if (!open) {
     PopID();
diff --git a/glass/src/lib/native/cpp/other/Drive.cpp b/glass/src/lib/native/cpp/other/Drive.cpp
index 9dc1675..a73c6de 100644
--- a/glass/src/lib/native/cpp/other/Drive.cpp
+++ b/glass/src/lib/native/cpp/other/Drive.cpp
@@ -90,11 +90,20 @@
     double a1 = 0.0;
     double a2 = wpi::numbers::pi / 2 * rotation;
 
-    draw->PathArcTo(center, radius, a1, a2, 20);
-    draw->PathStroke(color, false);
-    draw->PathArcTo(center, radius, a1 + wpi::numbers::pi,
-                    a2 + wpi::numbers::pi, 20);
-    draw->PathStroke(color, false);
+    // PathArcTo requires a_min <= a_max, and rotation can be negative
+    if (a1 > a2) {
+      draw->PathArcTo(center, radius, a2, a1, 20);
+      draw->PathStroke(color, false);
+      draw->PathArcTo(center, radius, a2 + wpi::numbers::pi,
+                      a1 + wpi::numbers::pi, 20);
+      draw->PathStroke(color, false);
+    } else {
+      draw->PathArcTo(center, radius, a1, a2, 20);
+      draw->PathStroke(color, false);
+      draw->PathArcTo(center, radius, a1 + wpi::numbers::pi,
+                      a2 + wpi::numbers::pi, 20);
+      draw->PathStroke(color, false);
+    }
 
     double adder = rotation < 0 ? wpi::numbers::pi : 0;
 
diff --git a/glass/src/lib/native/cpp/other/FMS.cpp b/glass/src/lib/native/cpp/other/FMS.cpp
index a19cad4..fbd504e 100644
--- a/glass/src/lib/native/cpp/other/FMS.cpp
+++ b/glass/src/lib/native/cpp/other/FMS.cpp
@@ -14,7 +14,7 @@
 static const char* stations[] = {"Red 1",  "Red 2",  "Red 3",
                                  "Blue 1", "Blue 2", "Blue 3"};
 
-void glass::DisplayFMS(FMSModel* model, bool* matchTimeEnabled) {
+void glass::DisplayFMS(FMSModel* model) {
   if (!model->Exists() || model->IsReadOnly()) {
     return DisplayFMSReadOnly(model);
   }
@@ -49,10 +49,6 @@
 
   // Match Time
   if (auto data = model->GetMatchTimeData()) {
-    if (matchTimeEnabled) {
-      ImGui::Checkbox("Match Time Enabled", matchTimeEnabled);
-    }
-
     double val = data->GetValue();
     ImGui::SetNextItemWidth(ImGui::GetFontSize() * 8);
     if (ImGui::InputDouble("Match Time", &val, 0, 0, "%.1f",
@@ -60,9 +56,17 @@
       model->SetMatchTime(val);
     }
     data->EmitDrag();
+    bool enabled = false;
+    if (auto enabledData = model->GetEnabledData()) {
+      enabled = enabledData->GetValue();
+    }
     ImGui::SameLine();
-    if (ImGui::Button("Reset")) {
-      model->SetMatchTime(0.0);
+    if (ImGui::Button("Auto") && !enabled) {
+      model->SetMatchTime(15.0);
+    }
+    ImGui::SameLine();
+    if (ImGui::Button("Teleop") && !enabled) {
+      model->SetMatchTime(135.0);
     }
   }
 
diff --git a/glass/src/lib/native/cpp/other/Field2D.cpp b/glass/src/lib/native/cpp/other/Field2D.cpp
index ec0210e..c3ef860 100644
--- a/glass/src/lib/native/cpp/other/Field2D.cpp
+++ b/glass/src/lib/native/cpp/other/Field2D.cpp
@@ -32,6 +32,9 @@
 #include <wpigui.h>
 
 #include "glass/Context.h"
+#include "glass/Storage.h"
+#include "glass/support/ColorSetting.h"
+#include "glass/support/EnumSetting.h"
 
 using namespace glass;
 
@@ -114,12 +117,14 @@
 
   static constexpr Style kDefaultStyle = kBoxImage;
   static constexpr float kDefaultWeight = 4.0f;
+  static constexpr float kDefaultColorFloat[] = {255, 0, 0, 255};
   static constexpr ImU32 kDefaultColor = IM_COL32(255, 0, 0, 255);
   static constexpr auto kDefaultWidth = 0.6858_m;
   static constexpr auto kDefaultLength = 0.8204_m;
   static constexpr bool kDefaultArrows = true;
   static constexpr int kDefaultArrowSize = 50;
   static constexpr float kDefaultArrowWeight = 4.0f;
+  static constexpr float kDefaultArrowColorFloat[] = {0, 255, 0, 255};
   static constexpr ImU32 kDefaultArrowColor = IM_COL32(0, 255, 0, 255);
   static constexpr bool kDefaultSelectable = true;
 
@@ -180,7 +185,7 @@
 
 class ObjectInfo {
  public:
-  ObjectInfo();
+  explicit ObjectInfo(Storage& storage);
 
   DisplayOptions GetDisplayOptions() const;
   void DisplaySettings();
@@ -191,26 +196,26 @@
 
  private:
   void Reset();
-  bool LoadImageImpl(const char* fn);
+  bool LoadImageImpl(const std::string& fn);
 
   std::unique_ptr<pfd::open_file> m_fileOpener;
 
   // in meters
-  float* m_pWidth;
-  float* m_pLength;
+  float& m_width;
+  float& m_length;
 
-  int* m_pStyle;  // DisplayOptions::Style
-  float* m_pWeight;
-  int* m_pColor;
+  EnumSetting m_style;  // DisplayOptions::Style
+  float& m_weight;
+  ColorSetting m_color;
 
-  bool* m_pArrows;
-  int* m_pArrowSize;
-  float* m_pArrowWeight;
-  int* m_pArrowColor;
+  bool& m_arrows;
+  int& m_arrowSize;
+  float& m_arrowWeight;
+  ColorSetting m_arrowColor;
 
-  bool* m_pSelectable;
+  bool& m_selectable;
 
-  std::string* m_pFilename;
+  std::string& m_filename;
   gui::Texture m_texture;
 };
 
@@ -219,7 +224,7 @@
   static constexpr auto kDefaultWidth = 15.98_m;
   static constexpr auto kDefaultHeight = 8.21_m;
 
-  FieldInfo();
+  explicit FieldInfo(Storage& storage);
 
   void DisplaySettings();
 
@@ -231,25 +236,25 @@
 
  private:
   void Reset();
-  bool LoadImageImpl(const char* fn);
+  bool LoadImageImpl(const std::string& fn);
   void LoadJson(std::string_view jsonfile);
 
   std::unique_ptr<pfd::open_file> m_fileOpener;
 
-  std::string* m_pFilename;
+  std::string& m_filename;
   gui::Texture m_texture;
 
   // in meters
-  float* m_pWidth;
-  float* m_pHeight;
+  float& m_width;
+  float& m_height;
 
   // in image pixels
   int m_imageWidth;
   int m_imageHeight;
-  int* m_pTop;
-  int* m_pLeft;
-  int* m_pBottom;
-  int* m_pRight;
+  int& m_top;
+  int& m_left;
+  int& m_bottom;
+  int& m_right;
 };
 
 }  // namespace
@@ -334,16 +339,14 @@
   return changed;
 }
 
-FieldInfo::FieldInfo() {
-  auto& storage = GetStorage();
-  m_pFilename = storage.GetStringRef("image");
-  m_pTop = storage.GetIntRef("top", 0);
-  m_pLeft = storage.GetIntRef("left", 0);
-  m_pBottom = storage.GetIntRef("bottom", -1);
-  m_pRight = storage.GetIntRef("right", -1);
-  m_pWidth = storage.GetFloatRef("width", kDefaultWidth.to<float>());
-  m_pHeight = storage.GetFloatRef("height", kDefaultHeight.to<float>());
-}
+FieldInfo::FieldInfo(Storage& storage)
+    : m_filename{storage.GetString("image")},
+      m_width{storage.GetFloat("width", kDefaultWidth.to<float>())},
+      m_height{storage.GetFloat("height", kDefaultHeight.to<float>())},
+      m_top{storage.GetInt("top", 0)},
+      m_left{storage.GetInt("left", 0)},
+      m_bottom{storage.GetInt("bottom", -1)},
+      m_right{storage.GetInt("right", -1)} {}
 
 void FieldInfo::DisplaySettings() {
   if (ImGui::Button("Choose image...")) {
@@ -357,23 +360,23 @@
   if (ImGui::Button("Reset image")) {
     Reset();
   }
-  InputFloatLength("Field Width", m_pWidth);
-  InputFloatLength("Field Height", m_pHeight);
-  // ImGui::InputInt("Field Top", m_pTop);
-  // ImGui::InputInt("Field Left", m_pLeft);
-  // ImGui::InputInt("Field Right", m_pRight);
-  // ImGui::InputInt("Field Bottom", m_pBottom);
+  InputFloatLength("Field Width", &m_width);
+  InputFloatLength("Field Height", &m_height);
+  // ImGui::InputInt("Field Top", &m_top);
+  // ImGui::InputInt("Field Left", &m_left);
+  // ImGui::InputInt("Field Right", &m_right);
+  // ImGui::InputInt("Field Bottom", &m_bottom);
 }
 
 void FieldInfo::Reset() {
   m_texture = gui::Texture{};
-  m_pFilename->clear();
+  m_filename.clear();
   m_imageWidth = 0;
   m_imageHeight = 0;
-  *m_pTop = 0;
-  *m_pLeft = 0;
-  *m_pBottom = -1;
-  *m_pRight = -1;
+  m_top = 0;
+  m_left = 0;
+  m_bottom = -1;
+  m_right = -1;
 }
 
 void FieldInfo::LoadImage() {
@@ -384,17 +387,17 @@
         LoadJson(result[0]);
       } else {
         LoadImageImpl(result[0].c_str());
-        *m_pTop = 0;
-        *m_pLeft = 0;
-        *m_pBottom = -1;
-        *m_pRight = -1;
+        m_top = 0;
+        m_left = 0;
+        m_bottom = -1;
+        m_right = -1;
       }
     }
     m_fileOpener.reset();
   }
-  if (!m_texture && !m_pFilename->empty()) {
-    if (!LoadImageImpl(m_pFilename->c_str())) {
-      m_pFilename->clear();
+  if (!m_texture && !m_filename.empty()) {
+    if (!LoadImageImpl(m_filename)) {
+      m_filename.clear();
     }
   }
 }
@@ -478,18 +481,18 @@
   }
 
   // save to field info
-  *m_pFilename = pathname;
-  *m_pTop = top;
-  *m_pLeft = left;
-  *m_pBottom = bottom;
-  *m_pRight = right;
-  *m_pWidth = width;
-  *m_pHeight = height;
+  m_filename = pathname;
+  m_top = top;
+  m_left = left;
+  m_bottom = bottom;
+  m_right = right;
+  m_width = width;
+  m_height = height;
 }
 
-bool FieldInfo::LoadImageImpl(const char* fn) {
+bool FieldInfo::LoadImageImpl(const std::string& fn) {
   fmt::print("GUI: loading field image '{}'\n", fn);
-  auto texture = gui::Texture::CreateFromFile(fn);
+  auto texture = gui::Texture::CreateFromFile(fn.c_str());
   if (!texture) {
     std::puts("GUI: could not read field image");
     return false;
@@ -497,7 +500,7 @@
   m_texture = std::move(texture);
   m_imageWidth = m_texture.GetWidth();
   m_imageHeight = m_texture.GetHeight();
-  *m_pFilename = fn;
+  m_filename = fn;
   return true;
 }
 
@@ -512,19 +515,19 @@
   ffd.imageMax = max;
 
   // size down the box by the image corners (if any)
-  if (*m_pBottom > 0 && *m_pRight > 0) {
-    min.x += *m_pLeft * (max.x - min.x) / m_imageWidth;
-    min.y += *m_pTop * (max.y - min.y) / m_imageHeight;
-    max.x -= (m_imageWidth - *m_pRight) * (max.x - min.x) / m_imageWidth;
-    max.y -= (m_imageHeight - *m_pBottom) * (max.y - min.y) / m_imageHeight;
+  if (m_bottom > 0 && m_right > 0) {
+    min.x += m_left * (max.x - min.x) / m_imageWidth;
+    min.y += m_top * (max.y - min.y) / m_imageHeight;
+    max.x -= (m_imageWidth - m_right) * (max.x - min.x) / m_imageWidth;
+    max.y -= (m_imageHeight - m_bottom) * (max.y - min.y) / m_imageHeight;
   }
 
   // draw the field "active area" as a yellow boundary box
-  gui::MaxFit(&min, &max, *m_pWidth, *m_pHeight);
+  gui::MaxFit(&min, &max, m_width, m_height);
 
   ffd.min = min;
   ffd.max = max;
-  ffd.scale = (max.x - min.x) / *m_pWidth;
+  ffd.scale = (max.x - min.x) / m_width;
   return ffd;
 }
 
@@ -537,48 +540,47 @@
   drawList->AddRect(ffd.min, ffd.max, IM_COL32(255, 255, 0, 255));
 }
 
-ObjectInfo::ObjectInfo() {
-  auto& storage = GetStorage();
-  m_pFilename = storage.GetStringRef("image");
-  m_pWidth =
-      storage.GetFloatRef("width", DisplayOptions::kDefaultWidth.to<float>());
-  m_pLength =
-      storage.GetFloatRef("length", DisplayOptions::kDefaultLength.to<float>());
-  m_pStyle = storage.GetIntRef("style", DisplayOptions::kDefaultStyle);
-  m_pWeight = storage.GetFloatRef("weight", DisplayOptions::kDefaultWeight);
-  m_pColor = storage.GetIntRef("color", DisplayOptions::kDefaultColor);
-  m_pArrows = storage.GetBoolRef("arrows", DisplayOptions::kDefaultArrows);
-  m_pArrowSize =
-      storage.GetIntRef("arrowSize", DisplayOptions::kDefaultArrowSize);
-  m_pArrowWeight =
-      storage.GetFloatRef("arrowWeight", DisplayOptions::kDefaultArrowWeight);
-  m_pArrowColor =
-      storage.GetIntRef("arrowColor", DisplayOptions::kDefaultArrowColor);
-  m_pSelectable =
-      storage.GetBoolRef("selectable", DisplayOptions::kDefaultSelectable);
-}
+ObjectInfo::ObjectInfo(Storage& storage)
+    : m_width{storage.GetFloat("width",
+                               DisplayOptions::kDefaultWidth.to<float>())},
+      m_length{storage.GetFloat("length",
+                                DisplayOptions::kDefaultLength.to<float>())},
+      m_style{storage.GetString("style"),
+              DisplayOptions::kDefaultStyle,
+              {"Box/Image", "Line", "Line (Closed)", "Track"}},
+      m_weight{storage.GetFloat("weight", DisplayOptions::kDefaultWeight)},
+      m_color{
+          storage.GetFloatArray("color", DisplayOptions::kDefaultColorFloat)},
+      m_arrows{storage.GetBool("arrows", DisplayOptions::kDefaultArrows)},
+      m_arrowSize{
+          storage.GetInt("arrowSize", DisplayOptions::kDefaultArrowSize)},
+      m_arrowWeight{
+          storage.GetFloat("arrowWeight", DisplayOptions::kDefaultArrowWeight)},
+      m_arrowColor{storage.GetFloatArray(
+          "arrowColor", DisplayOptions::kDefaultArrowColorFloat)},
+      m_selectable{
+          storage.GetBool("selectable", DisplayOptions::kDefaultSelectable)},
+      m_filename{storage.GetString("image")} {}
 
 DisplayOptions ObjectInfo::GetDisplayOptions() const {
   DisplayOptions rv{m_texture};
-  rv.style = static_cast<DisplayOptions::Style>(*m_pStyle);
-  rv.weight = *m_pWeight;
-  rv.color = *m_pColor;
-  rv.width = units::meter_t{*m_pWidth};
-  rv.length = units::meter_t{*m_pLength};
-  rv.arrows = *m_pArrows;
-  rv.arrowSize = *m_pArrowSize;
-  rv.arrowWeight = *m_pArrowWeight;
-  rv.arrowColor = *m_pArrowColor;
-  rv.selectable = *m_pSelectable;
+  rv.style = static_cast<DisplayOptions::Style>(m_style.GetValue());
+  rv.weight = m_weight;
+  rv.color = ImGui::ColorConvertFloat4ToU32(m_color.GetColor());
+  rv.width = units::meter_t{m_width};
+  rv.length = units::meter_t{m_length};
+  rv.arrows = m_arrows;
+  rv.arrowSize = m_arrowSize;
+  rv.arrowWeight = m_arrowWeight;
+  rv.arrowColor = ImGui::ColorConvertFloat4ToU32(m_arrowColor.GetColor());
+  rv.selectable = m_selectable;
   return rv;
 }
 
 void ObjectInfo::DisplaySettings() {
-  static const char* styleChoices[] = {"Box/Image", "Line", "Line (Closed)",
-                                       "Track"};
   ImGui::SetNextItemWidth(ImGui::GetFontSize() * 8);
-  ImGui::Combo("Style", m_pStyle, styleChoices, IM_ARRAYSIZE(styleChoices));
-  switch (*m_pStyle) {
+  m_style.Combo("Style");
+  switch (m_style.GetValue()) {
     case DisplayOptions::kBoxImage:
       if (ImGui::Button("Choose image...")) {
         m_fileOpener = std::make_unique<pfd::open_file>(
@@ -591,35 +593,27 @@
       if (ImGui::Button("Reset image")) {
         Reset();
       }
-      InputFloatLength("Width", m_pWidth);
-      InputFloatLength("Length", m_pLength);
+      InputFloatLength("Width", &m_width);
+      InputFloatLength("Length", &m_length);
       break;
     case DisplayOptions::kTrack:
-      InputFloatLength("Width", m_pWidth);
+      InputFloatLength("Width", &m_width);
       break;
     default:
       break;
   }
 
-  ImGui::InputFloat("Line Weight", m_pWeight);
-  ImColor col(*m_pColor);
-  if (ImGui::ColorEdit3("Line Color", &col.Value.x,
-                        ImGuiColorEditFlags_NoInputs)) {
-    *m_pColor = col;
-  }
-  ImGui::Checkbox("Arrows", m_pArrows);
-  if (*m_pArrows) {
-    ImGui::SliderInt("Arrow Size", m_pArrowSize, 0, 100, "%d%%",
+  ImGui::InputFloat("Line Weight", &m_weight);
+  m_color.ColorEdit3("Line Color", ImGuiColorEditFlags_NoInputs);
+  ImGui::Checkbox("Arrows", &m_arrows);
+  if (m_arrows) {
+    ImGui::SliderInt("Arrow Size", &m_arrowSize, 0, 100, "%d%%",
                      ImGuiSliderFlags_AlwaysClamp);
-    ImGui::InputFloat("Arrow Weight", m_pArrowWeight);
-    ImColor col(*m_pArrowColor);
-    if (ImGui::ColorEdit3("Arrow Color", &col.Value.x,
-                          ImGuiColorEditFlags_NoInputs)) {
-      *m_pArrowColor = col;
-    }
+    ImGui::InputFloat("Arrow Weight", &m_arrowWeight);
+    m_arrowColor.ColorEdit3("Arrow Color", ImGuiColorEditFlags_NoInputs);
   }
 
-  ImGui::Checkbox("Selectable", m_pSelectable);
+  ImGui::Checkbox("Selectable", &m_selectable);
 }
 
 void ObjectInfo::DrawLine(ImDrawList* drawList,
@@ -629,10 +623,12 @@
   }
 
   if (points.size() == 1) {
-    drawList->AddCircleFilled(points.front(), *m_pWeight, *m_pWeight);
+    drawList->AddCircleFilled(points.front(), m_weight, m_weight);
     return;
   }
 
+  ImU32 color = ImGui::ColorConvertFloat4ToU32(m_color.GetColor());
+
   // PolyLine doesn't handle acute angles well; workaround from
   // https://github.com/ocornut/imgui/issues/3366
   size_t i = 0;
@@ -651,18 +647,18 @@
       ++nlin;
     }
 
-    drawList->AddPolyline(&points[i], nlin, *m_pColor, false, *m_pWeight);
+    drawList->AddPolyline(&points[i], nlin, color, false, m_weight);
     i += nlin - 1;
   }
 
-  if (points.size() > 2 && *m_pStyle == DisplayOptions::kLineClosed) {
-    drawList->AddLine(points.back(), points.front(), *m_pColor, *m_pWeight);
+  if (points.size() > 2 && m_style.GetValue() == DisplayOptions::kLineClosed) {
+    drawList->AddLine(points.back(), points.front(), color, m_weight);
   }
 }
 
 void ObjectInfo::Reset() {
   m_texture = gui::Texture{};
-  m_pFilename->clear();
+  m_filename.clear();
 }
 
 void ObjectInfo::LoadImage() {
@@ -673,22 +669,22 @@
     }
     m_fileOpener.reset();
   }
-  if (!m_texture && !m_pFilename->empty()) {
-    if (!LoadImageImpl(m_pFilename->c_str())) {
-      m_pFilename->clear();
+  if (!m_texture && !m_filename.empty()) {
+    if (!LoadImageImpl(m_filename)) {
+      m_filename.clear();
     }
   }
 }
 
-bool ObjectInfo::LoadImageImpl(const char* fn) {
+bool ObjectInfo::LoadImageImpl(const std::string& fn) {
   fmt::print("GUI: loading object image '{}'\n", fn);
-  auto texture = gui::Texture::CreateFromFile(fn);
+  auto texture = gui::Texture::CreateFromFile(fn.c_str());
   if (!texture) {
     std::fputs("GUI: could not read object image\n", stderr);
     return false;
   }
   m_texture = std::move(texture);
-  *m_pFilename = fn;
+  m_filename = fn;
   return true;
 }
 
@@ -857,15 +853,16 @@
   auto& storage = GetStorage();
   auto field = storage.GetData<FieldInfo>();
   if (!field) {
-    storage.SetData(std::make_shared<FieldInfo>());
+    storage.SetData(std::make_shared<FieldInfo>(storage));
     field = storage.GetData<FieldInfo>();
   }
 
-  static const char* unitNames[] = {"meters", "feet", "inches"};
-  int* pDisplayUnits = GetStorage().GetIntRef("units", kDisplayMeters);
+  EnumSetting displayUnits{GetStorage().GetString("units"),
+                           kDisplayMeters,
+                           {"meters", "feet", "inches"}};
   ImGui::SetNextItemWidth(ImGui::GetFontSize() * 8);
-  ImGui::Combo("Units", pDisplayUnits, unitNames, IM_ARRAYSIZE(unitNames));
-  gDisplayUnits = static_cast<DisplayUnits>(*pDisplayUnits);
+  displayUnits.Combo("Units");
+  gDisplayUnits = static_cast<DisplayUnits>(displayUnits.GetValue());
 
   ImGui::PushItemWidth(ImGui::GetFontSize() * 4);
   if (ImGui::CollapsingHeader("Field")) {
@@ -881,7 +878,7 @@
     PushID(name);
     auto& objRef = field->m_objects[name];
     if (!objRef) {
-      objRef = std::make_unique<ObjectInfo>();
+      objRef = std::make_unique<ObjectInfo>(GetStorage());
     }
     auto obj = objRef.get();
 
@@ -1025,7 +1022,7 @@
   PushID(name);
   auto& objRef = m_field->m_objects[name];
   if (!objRef) {
-    objRef = std::make_unique<ObjectInfo>();
+    objRef = std::make_unique<ObjectInfo>(GetStorage());
   }
   auto obj = objRef.get();
   obj->LoadImage();
@@ -1205,7 +1202,7 @@
   auto& storage = GetStorage();
   auto field = storage.GetData<FieldInfo>();
   if (!field) {
-    storage.SetData(std::make_shared<FieldInfo>());
+    storage.SetData(std::make_shared<FieldInfo>(storage));
     field = storage.GetData<FieldInfo>();
   }
 
diff --git a/glass/src/lib/native/cpp/other/Mechanism2D.cpp b/glass/src/lib/native/cpp/other/Mechanism2D.cpp
index 07e83e2..aa801a7 100644
--- a/glass/src/lib/native/cpp/other/Mechanism2D.cpp
+++ b/glass/src/lib/native/cpp/other/Mechanism2D.cpp
@@ -27,6 +27,7 @@
 #include <wpigui.h>
 
 #include "glass/Context.h"
+#include "glass/Storage.h"
 
 using namespace glass;
 
@@ -61,7 +62,7 @@
 
 class BackgroundInfo {
  public:
-  BackgroundInfo();
+  explicit BackgroundInfo(Storage& storage);
 
   void DisplaySettings();
 
@@ -72,11 +73,11 @@
 
  private:
   void Reset();
-  bool LoadImageImpl(const char* fn);
+  bool LoadImageImpl(const std::string& fn);
 
   std::unique_ptr<pfd::open_file> m_fileOpener;
 
-  std::string* m_pFilename;
+  std::string& m_filename;
   gui::Texture m_texture;
 
   // in image pixels
@@ -86,10 +87,8 @@
 
 }  // namespace
 
-BackgroundInfo::BackgroundInfo() {
-  auto& storage = GetStorage();
-  m_pFilename = storage.GetStringRef("image");
-}
+BackgroundInfo::BackgroundInfo(Storage& storage)
+    : m_filename{storage.GetString("image")} {}
 
 void BackgroundInfo::DisplaySettings() {
   if (ImGui::Button("Choose image...")) {
@@ -106,7 +105,7 @@
 
 void BackgroundInfo::Reset() {
   m_texture = gui::Texture{};
-  m_pFilename->clear();
+  m_filename.clear();
   m_imageWidth = 0;
   m_imageHeight = 0;
 }
@@ -119,16 +118,16 @@
     }
     m_fileOpener.reset();
   }
-  if (!m_texture && !m_pFilename->empty()) {
-    if (!LoadImageImpl(m_pFilename->c_str())) {
-      m_pFilename->clear();
+  if (!m_texture && !m_filename.empty()) {
+    if (!LoadImageImpl(m_filename)) {
+      m_filename.clear();
     }
   }
 }
 
-bool BackgroundInfo::LoadImageImpl(const char* fn) {
+bool BackgroundInfo::LoadImageImpl(const std::string& fn) {
   fmt::print("GUI: loading background image '{}'\n", fn);
-  auto texture = gui::Texture::CreateFromFile(fn);
+  auto texture = gui::Texture::CreateFromFile(fn.c_str());
   if (!texture) {
     std::puts("GUI: could not read background image");
     return false;
@@ -136,7 +135,7 @@
   m_texture = std::move(texture);
   m_imageWidth = m_texture.GetWidth();
   m_imageHeight = m_texture.GetHeight();
-  *m_pFilename = fn;
+  m_filename = fn;
   return true;
 }
 
@@ -175,7 +174,7 @@
   auto& storage = GetStorage();
   auto bg = storage.GetData<BackgroundInfo>();
   if (!bg) {
-    storage.SetData(std::make_shared<BackgroundInfo>());
+    storage.SetData(std::make_shared<BackgroundInfo>(storage));
     bg = storage.GetData<BackgroundInfo>();
   }
   bg->DisplaySettings();
@@ -208,7 +207,7 @@
   auto& storage = GetStorage();
   auto bg = storage.GetData<BackgroundInfo>();
   if (!bg) {
-    storage.SetData(std::make_shared<BackgroundInfo>());
+    storage.SetData(std::make_shared<BackgroundInfo>(storage));
     bg = storage.GetData<BackgroundInfo>();
   }
 
diff --git a/glass/src/lib/native/cpp/other/Plot.cpp b/glass/src/lib/native/cpp/other/Plot.cpp
index 372f8c9..bff55b7 100644
--- a/glass/src/lib/native/cpp/other/Plot.cpp
+++ b/glass/src/lib/native/cpp/other/Plot.cpp
@@ -17,12 +17,15 @@
 
 #include <fmt/format.h>
 
+#if defined(__GNUC__)
+#pragma GCC diagnostic ignored "-Wformat-nonliteral"
+#endif
+
 #define IMGUI_DEFINE_MATH_OPERATORS
 #include <imgui.h>
-#include <imgui_internal.h>
 #include <imgui_stdlib.h>
 #include <implot.h>
-#include <wpigui.h>
+#include <implot_internal.h>
 #include <wpi/Signal.h>
 #include <wpi/SmallString.h>
 #include <wpi/SmallVector.h>
@@ -31,10 +34,15 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
+#include "glass/support/ColorSetting.h"
+#include "glass/support/EnumSetting.h"
 #include "glass/support/ExtraGuiWidgets.h"
 
 using namespace glass;
 
+static constexpr int kAxisCount = 3;
+
 namespace {
 class PlotView;
 
@@ -45,9 +53,11 @@
 };
 
 class PlotSeries {
+  explicit PlotSeries(Storage& storage, int yAxis = 0);
+
  public:
-  explicit PlotSeries(std::string_view id);
-  explicit PlotSeries(DataSource* source, int yAxis = 0);
+  PlotSeries(Storage& storage, std::string_view id);
+  PlotSeries(Storage& storage, DataSource* source, int yAxis = 0);
 
   const std::string& GetId() const { return m_id; }
 
@@ -56,9 +66,6 @@
   void SetSource(DataSource* source);
   DataSource* GetSource() const { return m_source; }
 
-  bool ReadIni(std::string_view name, std::string_view value);
-  void WriteIni(ImGuiTextBuffer* out);
-
   enum Action { kNone, kMoveUp, kMoveDown, kDelete };
   Action EmitPlot(PlotView& view, double now, size_t i, size_t plotIndex);
   void EmitSettings(size_t i);
@@ -69,10 +76,12 @@
   int GetYAxis() const { return m_yAxis; }
   void SetYAxis(int yAxis) { m_yAxis = yAxis; }
 
+  void SetColor(const ImVec4& color) { m_color.SetColor(color); }
+
  private:
   bool IsDigital() const {
-    return m_digital == kDigital ||
-           (m_digital == kAuto && m_source && m_source->IsDigital());
+    return m_digital.GetValue() == kDigital ||
+           (m_digital.GetValue() == kAuto && m_source && m_source->IsDigital());
   }
   void AppendValue(double value, uint64_t time);
 
@@ -80,22 +89,23 @@
   DataSource* m_source = nullptr;
   wpi::sig::ScopedConnection m_sourceCreatedConn;
   wpi::sig::ScopedConnection m_newValueConn;
-  std::string m_id;
+  std::string& m_id;
 
   // user settings
-  std::string m_name;
-  int m_yAxis = 0;
-  ImVec4 m_color = IMPLOT_AUTO_COL;
-  int m_marker = 0;
-  float m_weight = IMPLOT_AUTO;
+  std::string& m_name;
+  int& m_yAxis;
+  static constexpr float kDefaultColor[4] = {0.0, 0.0, 0.0, IMPLOT_AUTO};
+  ColorSetting m_color;
+  EnumSetting m_marker;
+  float& m_weight;
 
   enum Digital { kAuto, kDigital, kAnalog };
-  int m_digital = 0;
-  int m_digitalBitHeight = 8;
-  int m_digitalBitGap = 4;
+  EnumSetting m_digital;
+  int& m_digitalBitHeight;
+  int& m_digitalBitGap;
 
   // value storage
-  static constexpr int kMaxSize = 2000;
+  static constexpr int kMaxSize = 20000;
   static constexpr double kTimeGap = 0.05;
   std::atomic<int> m_size = 0;
   std::atomic<int> m_offset = 0;
@@ -104,10 +114,7 @@
 
 class Plot {
  public:
-  Plot();
-
-  bool ReadIni(std::string_view name, std::string_view value);
-  void WriteIni(ImGuiTextBuffer* out);
+  explicit Plot(Storage& storage);
 
   void DragDropTarget(PlotView& view, size_t i, bool inPlot);
   void EmitPlot(PlotView& view, double now, bool paused, size_t i);
@@ -116,6 +123,7 @@
   const std::string& GetName() const { return m_name; }
 
   std::vector<std::unique_ptr<PlotSeries>> m_series;
+  std::vector<std::unique_ptr<Storage>>& m_seriesStorage;
 
   // Returns base height; does not include actual plot height if auto-sized.
   int GetAutoBaseHeight(bool* isAuto, size_t i);
@@ -128,31 +136,50 @@
 
  private:
   void EmitSettingsLimits(int axis);
+  void DragDropAccept(PlotView& view, size_t i, int yAxis);
 
-  std::string m_name;
-  bool m_visible = true;
-  bool m_showPause = true;
-  unsigned int m_plotFlags = ImPlotFlags_None;
-  bool m_lockPrevX = false;
   bool m_paused = false;
-  float m_viewTime = 10;
-  bool m_autoHeight = true;
-  int m_height = 300;
-  struct PlotRange {
-    double min = 0;
-    double max = 1;
-    bool lockMin = false;
-    bool lockMax = false;
+
+  std::string& m_name;
+  bool& m_visible;
+  bool& m_showPause;
+  bool& m_lockPrevX;
+  bool& m_legend;
+  bool& m_legendOutside;
+  bool& m_legendHorizontal;
+  int& m_legendLocation;
+  bool& m_crosshairs;
+  bool& m_antialiased;
+  bool& m_mousePosition;
+  bool& m_yAxis2;
+  bool& m_yAxis3;
+  float& m_viewTime;
+  bool& m_autoHeight;
+  int& m_height;
+  struct PlotAxis {
+    PlotAxis(Storage& storage, int num);
+
+    std::string& label;
+    double& min;
+    double& max;
+    bool& lockMin;
+    bool& lockMax;
     bool apply = false;
+    bool& autoFit;
+    bool& logScale;
+    bool& invert;
+    bool& opposite;
+    bool& gridLines;
+    bool& tickMarks;
+    bool& tickLabels;
   };
-  std::string m_axisLabel[3];
-  PlotRange m_axisRange[3];
+  std::vector<PlotAxis> m_axis;
   ImPlotRange m_xaxisRange;  // read from plot, used for lockPrevX
 };
 
 class PlotView : public View {
  public:
-  explicit PlotView(PlotProvider* provider) : m_provider{provider} {}
+  PlotView(PlotProvider* provider, Storage& storage);
 
   void Display() override;
 
@@ -163,12 +190,32 @@
                       size_t toSeriesIndex, int yAxis = -1);
 
   PlotProvider* m_provider;
+  std::vector<std::unique_ptr<Storage>>& m_plotsStorage;
   std::vector<std::unique_ptr<Plot>> m_plots;
 };
 
 }  // namespace
 
-PlotSeries::PlotSeries(std::string_view id) : m_id(id) {
+PlotSeries::PlotSeries(Storage& storage, int yAxis)
+    : m_id{storage.GetString("id")},
+      m_name{storage.GetString("name")},
+      m_yAxis{storage.GetInt("yAxis", 0)},
+      m_color{storage.GetFloatArray("color", kDefaultColor)},
+      m_marker{storage.GetString("marker"),
+               0,
+               {"None", "Circle", "Square", "Diamond", "Up", "Down", "Left",
+                "Right", "Cross", "Plus", "Asterisk"}},
+      m_weight{storage.GetFloat("weight", IMPLOT_AUTO)},
+      m_digital{
+          storage.GetString("digital"), kAuto, {"Auto", "Digital", "Analog"}},
+      m_digitalBitHeight{storage.GetInt("digitalBitHeight", 8)},
+      m_digitalBitGap{storage.GetInt("digitalBitGap", 4)} {
+  m_yAxis = yAxis;
+}
+
+PlotSeries::PlotSeries(Storage& storage, std::string_view id)
+    : PlotSeries{storage, 0} {
+  m_id = id;
   if (DataSource* source = DataSource::Find(id)) {
     SetSource(source);
     return;
@@ -176,7 +223,8 @@
   CheckSource();
 }
 
-PlotSeries::PlotSeries(DataSource* source, int yAxis) : m_yAxis(yAxis) {
+PlotSeries::PlotSeries(Storage& storage, DataSource* source, int yAxis)
+    : PlotSeries{storage, yAxis} {
   SetSource(source);
   m_id = source->GetId();
 }
@@ -198,7 +246,7 @@
   m_source = source;
 
   // add initial value
-  m_data[m_size++] = ImPlotPoint{wpi::Now() * 1.0e-6, source->GetValue()};
+  AppendValue(source->GetValue(), 0);
 
   m_newValueConn = source->valueChanged.connect_connection(
       [this](double value, uint64_t time) { AppendValue(value, time); });
@@ -245,66 +293,14 @@
   }
 }
 
-bool PlotSeries::ReadIni(std::string_view name, std::string_view value) {
-  if (name == "name") {
-    m_name = value;
-    return true;
-  }
-  if (name == "yAxis") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_yAxis = num.value();
-    }
-    return true;
-  } else if (name == "color") {
-    if (auto num = wpi::parse_integer<unsigned int>(value, 10)) {
-      m_color = ImColor(num.value());
-    }
-    return true;
-  } else if (name == "marker") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_marker = num.value();
-    }
-    return true;
-  } else if (name == "weight") {
-    if (auto num = wpi::parse_float<float>(value)) {
-      m_weight = num.value();
-    }
-    return true;
-  } else if (name == "digital") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_digital = num.value();
-    }
-    return true;
-  } else if (name == "digitalBitHeight") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_digitalBitHeight = num.value();
-    }
-    return true;
-  } else if (name == "digitalBitGap") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_digitalBitGap = num.value();
-    }
-    return true;
-  }
-  return false;
-}
-
-void PlotSeries::WriteIni(ImGuiTextBuffer* out) {
-  out->appendf(
-      "name=%s\nyAxis=%d\ncolor=%u\nmarker=%d\nweight=%f\ndigital=%d\n"
-      "digitalBitHeight=%d\ndigitalBitGap=%d\n",
-      m_name.c_str(), m_yAxis, static_cast<ImU32>(ImColor(m_color)), m_marker,
-      m_weight, m_digital, m_digitalBitHeight, m_digitalBitGap);
-}
-
 const char* PlotSeries::GetName() const {
   if (!m_name.empty()) {
     return m_name.c_str();
   }
   if (m_newValueConn.connected()) {
-    auto sourceName = m_source->GetName();
-    if (sourceName[0] != '\0') {
-      return sourceName;
+    auto& sourceName = m_source->GetName();
+    if (!sourceName.empty()) {
+      return sourceName.c_str();
     }
   }
   return m_id.c_str();
@@ -315,7 +311,8 @@
   CheckSource();
 
   char label[128];
-  std::snprintf(label, sizeof(label), "%s###name", GetName());
+  std::snprintf(label, sizeof(label), "%s###name%d_%d", GetName(),
+                static_cast<int>(i), static_cast<int>(plotIndex));
 
   int size = m_size;
   int offset = m_offset;
@@ -346,10 +343,10 @@
     return ImPlotPoint{point->x - d->zeroTime, point->y};
   };
 
-  if (m_color.w == IMPLOT_AUTO_COL.w) {
-    m_color = ImPlot::GetColormapColor(i);
+  if (m_color.GetColorFloat()[3] == IMPLOT_AUTO) {
+    SetColor(ImPlot::GetColormapColor(i));
   }
-  ImPlot::SetNextLineStyle(m_color, m_weight);
+  ImPlot::SetNextLineStyle(m_color.GetColor(), m_weight);
   if (IsDigital()) {
     ImPlot::PushStyleVar(ImPlotStyleVar_DigitalBitHeight, m_digitalBitHeight);
     ImPlot::PushStyleVar(ImPlotStyleVar_DigitalBitGap, m_digitalBitGap);
@@ -357,8 +354,12 @@
     ImPlot::PopStyleVar();
     ImPlot::PopStyleVar();
   } else {
-    ImPlot::SetPlotYAxis(m_yAxis);
-    ImPlot::SetNextMarkerStyle(m_marker - 1);
+    if (ImPlot::GetCurrentPlot()->YAxis(m_yAxis).Enabled) {
+      ImPlot::SetAxis(ImAxis_Y1 + m_yAxis);
+    } else {
+      ImPlot::SetAxis(ImAxis_Y1);
+    }
+    ImPlot::SetNextMarkerStyle(m_marker.GetValue() - 1);
     ImPlot::PlotLineG(label, getter, &getterData, size + 1);
   }
 
@@ -413,10 +414,10 @@
 void PlotSeries::EmitSettings(size_t i) {
   // Line color
   {
-    ImGui::ColorEdit3("Color", &m_color.x, ImGuiColorEditFlags_NoInputs);
+    m_color.ColorEdit3("Color", ImGuiColorEditFlags_NoInputs);
     ImGui::SameLine();
     if (ImGui::Button("Default")) {
-      m_color = ImPlot::GetColormapColor(i);
+      SetColor(ImPlot::GetColormapColor(i));
     }
   }
 
@@ -428,10 +429,8 @@
 
   // Digital
   {
-    static const char* const options[] = {"Auto", "Digital", "Analog"};
     ImGui::SetNextItemWidth(ImGui::GetFontSize() * 6);
-    ImGui::Combo("Digital", &m_digital, options,
-                 sizeof(options) / sizeof(options[0]));
+    m_digital.Combo("Digital");
   }
 
   if (IsDigital()) {
@@ -456,152 +455,62 @@
 
     // Marker
     {
-      static const char* const options[] = {
-          "None", "Circle", "Square", "Diamond", "Up",      "Down",
-          "Left", "Right",  "Cross",  "Plus",    "Asterisk"};
       ImGui::SetNextItemWidth(ImGui::GetFontSize() * 8);
-      ImGui::Combo("Marker", &m_marker, options,
-                   sizeof(options) / sizeof(options[0]));
+      m_marker.Combo("Marker");
     }
   }
 }
 
-Plot::Plot() {
-  for (int i = 0; i < 3; ++i) {
-    m_axisRange[i] = PlotRange{};
+Plot::PlotAxis::PlotAxis(Storage& storage, int num)
+    : label{storage.GetString("label")},
+      min{storage.GetDouble("min", 0)},
+      max{storage.GetDouble("max", 1)},
+      lockMin{storage.GetBool("lockMin", false)},
+      lockMax{storage.GetBool("lockMax", false)},
+      autoFit{storage.GetBool("autoFit", false)},
+      logScale{storage.GetBool("logScale", false)},
+      invert{storage.GetBool("invert", false)},
+      opposite{storage.GetBool("opposite", num != 0)},
+      gridLines{storage.GetBool("gridLines", num == 0)},
+      tickMarks{storage.GetBool("tickMarks", true)},
+      tickLabels{storage.GetBool("tickLabels", true)} {}
+
+Plot::Plot(Storage& storage)
+    : m_seriesStorage{storage.GetChildArray("series")},
+      m_name{storage.GetString("name")},
+      m_visible{storage.GetBool("visible", true)},
+      m_showPause{storage.GetBool("showPause", true)},
+      m_lockPrevX{storage.GetBool("lockPrevX", false)},
+      m_legend{storage.GetBool("legend", true)},
+      m_legendOutside{storage.GetBool("legendOutside", false)},
+      m_legendHorizontal{storage.GetBool("legendHorizontal", false)},
+      m_legendLocation{
+          storage.GetInt("legendLocation", ImPlotLocation_NorthWest)},
+      m_crosshairs{storage.GetBool("crosshairs", false)},
+      m_antialiased{storage.GetBool("antialiased", false)},
+      m_mousePosition{storage.GetBool("mousePosition", true)},
+      m_yAxis2{storage.GetBool("yaxis2", false)},
+      m_yAxis3{storage.GetBool("yaxis3", false)},
+      m_viewTime{storage.GetFloat("viewTime", 10)},
+      m_autoHeight{storage.GetBool("autoHeight", true)},
+      m_height{storage.GetInt("height", 300)} {
+  auto& axesStorage = storage.GetChildArray("axis");
+  axesStorage.resize(kAxisCount);
+  for (int i = 0; i < kAxisCount; ++i) {
+    if (!axesStorage[i]) {
+      axesStorage[i] = std::make_unique<Storage>();
+    }
+    m_axis.emplace_back(*axesStorage[i], i);
+  }
+
+  // loop over series
+  for (auto&& v : m_seriesStorage) {
+    m_series.emplace_back(
+        std::make_unique<PlotSeries>(*v, v->ReadString("id")));
   }
 }
 
-bool Plot::ReadIni(std::string_view name, std::string_view value) {
-  if (name == "name") {
-    m_name = value;
-    return true;
-  } else if (name == "visible") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_visible = num.value() != 0;
-    }
-    return true;
-  } else if (name == "showPause") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_showPause = num.value() != 0;
-    }
-    return true;
-  } else if (name == "lockPrevX") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_lockPrevX = num.value() != 0;
-    }
-    return true;
-  } else if (name == "legend") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      if (num.value() == 0) {
-        m_plotFlags |= ImPlotFlags_NoLegend;
-      } else {
-        m_plotFlags &= ~ImPlotFlags_NoLegend;
-      }
-    }
-    return true;
-  } else if (name == "yaxis2") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      if (num.value() == 0) {
-        m_plotFlags &= ~ImPlotFlags_YAxis2;
-      } else {
-        m_plotFlags |= ImPlotFlags_YAxis2;
-      }
-    }
-    return true;
-  } else if (name == "yaxis3") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      if (num.value() == 0) {
-        m_plotFlags &= ~ImPlotFlags_YAxis3;
-      } else {
-        m_plotFlags |= ImPlotFlags_YAxis3;
-      }
-    }
-    return true;
-  } else if (name == "viewTime") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_viewTime = num.value() / 1000.0;
-    }
-    return true;
-  } else if (name == "autoHeight") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_autoHeight = num.value() != 0;
-    }
-    return true;
-  } else if (name == "height") {
-    if (auto num = wpi::parse_integer<int>(value, 10)) {
-      m_height = num.value();
-    }
-    return true;
-  } else if (wpi::starts_with(name, 'y')) {
-    auto [yAxisStr, yName] = wpi::split(name, '_');
-    int yAxis =
-        wpi::parse_integer<int>(wpi::drop_front(yAxisStr), 10).value_or(-1);
-    if (yAxis < 0 || yAxis > 3) {
-      return false;
-    }
-    if (yName == "min") {
-      if (auto num = wpi::parse_integer<int>(value, 10)) {
-        m_axisRange[yAxis].min = num.value() / 1000.0;
-      }
-      return true;
-    } else if (yName == "max") {
-      if (auto num = wpi::parse_integer<int>(value, 10)) {
-        m_axisRange[yAxis].max = num.value() / 1000.0;
-      }
-      return true;
-    } else if (yName == "lockMin") {
-      if (auto num = wpi::parse_integer<int>(value, 10)) {
-        m_axisRange[yAxis].lockMin = num.value() != 0;
-      }
-      return true;
-    } else if (yName == "lockMax") {
-      if (auto num = wpi::parse_integer<int>(value, 10)) {
-        m_axisRange[yAxis].lockMax = num.value() != 0;
-      }
-      return true;
-    } else if (yName == "label") {
-      m_axisLabel[yAxis] = value;
-      return true;
-    }
-  }
-  return false;
-}
-
-void Plot::WriteIni(ImGuiTextBuffer* out) {
-  out->appendf(
-      "name=%s\nvisible=%d\nshowPause=%d\nlockPrevX=%d\nlegend=%d\n"
-      "yaxis2=%d\nyaxis3=%d\nviewTime=%d\nautoHeight=%d\nheight=%d\n",
-      m_name.c_str(), m_visible ? 1 : 0, m_showPause ? 1 : 0,
-      m_lockPrevX ? 1 : 0, (m_plotFlags & ImPlotFlags_NoLegend) ? 0 : 1,
-      (m_plotFlags & ImPlotFlags_YAxis2) ? 1 : 0,
-      (m_plotFlags & ImPlotFlags_YAxis3) ? 1 : 0,
-      static_cast<int>(m_viewTime * 1000), m_autoHeight ? 1 : 0, m_height);
-  for (int i = 0; i < 3; ++i) {
-    out->appendf(
-        "y%d_min=%d\ny%d_max=%d\ny%d_lockMin=%d\ny%d_lockMax=%d\n"
-        "y%d_label=%s\n",
-        i, static_cast<int>(m_axisRange[i].min * 1000), i,
-        static_cast<int>(m_axisRange[i].max * 1000), i,
-        m_axisRange[i].lockMin ? 1 : 0, i, m_axisRange[i].lockMax ? 1 : 0, i,
-        m_axisLabel[i].c_str());
-  }
-}
-
-void Plot::DragDropTarget(PlotView& view, size_t i, bool inPlot) {
-  if (!ImGui::BeginDragDropTarget()) {
-    return;
-  }
-  // handle dragging onto a specific Y axis
-  int yAxis = -1;
-  if (inPlot) {
-    for (int y = 0; y < 3; ++y) {
-      if (ImPlot::IsPlotYAxisHovered(y)) {
-        yAxis = y;
-        break;
-      }
-    }
-  }
+void Plot::DragDropAccept(PlotView& view, size_t i, int yAxis) {
   if (const ImGuiPayload* payload =
           ImGui::AcceptDragDropPayload("DataSource")) {
     auto source = *static_cast<DataSource**>(payload->Data);
@@ -612,8 +521,9 @@
                  (yAxis == -1 || elem->GetYAxis() == yAxis);
         });
     if (it == m_series.end()) {
-      m_series.emplace_back(
-          std::make_unique<PlotSeries>(source, yAxis == -1 ? 0 : yAxis));
+      m_seriesStorage.emplace_back(std::make_unique<Storage>());
+      m_series.emplace_back(std::make_unique<PlotSeries>(
+          *m_seriesStorage.back(), source, yAxis == -1 ? 0 : yAxis));
     }
   } else if (const ImGuiPayload* payload =
                  ImGui::AcceptDragDropPayload("PlotSeries")) {
@@ -627,6 +537,26 @@
   }
 }
 
+void Plot::DragDropTarget(PlotView& view, size_t i, bool inPlot) {
+  if (inPlot) {
+    if (ImPlot::BeginDragDropTargetPlot() ||
+        ImPlot::BeginDragDropTargetLegend()) {
+      DragDropAccept(view, i, -1);
+      ImPlot::EndDragDropTarget();
+    }
+    for (int y = 0; y < kAxisCount; ++y) {
+      if (ImPlot::GetCurrentPlot()->YAxis(y).Enabled &&
+          ImPlot::BeginDragDropTargetAxis(ImAxis_Y1 + y)) {
+        DragDropAccept(view, i, y);
+        ImPlot::EndDragDropTarget();
+      }
+    }
+  } else if (ImGui::BeginDragDropTarget()) {
+    DragDropAccept(view, i, -1);
+    ImGui::EndDragDropTarget();
+  }
+}
+
 void Plot::EmitPlot(PlotView& view, double now, bool paused, size_t i) {
   if (!m_visible) {
     return;
@@ -639,67 +569,115 @@
   }
 
   char label[128];
-  std::snprintf(label, sizeof(label), "%s##plot", m_name.c_str());
+  std::snprintf(label, sizeof(label), "%s###plot%d", m_name.c_str(),
+                static_cast<int>(i));
+  ImPlotFlags plotFlags = (m_legend ? 0 : ImPlotFlags_NoLegend) |
+                          (m_crosshairs ? ImPlotFlags_Crosshairs : 0) |
+                          (m_antialiased ? ImPlotFlags_AntiAliased : 0) |
+                          (m_mousePosition ? 0 : ImPlotFlags_NoMouseText);
 
-  if (lockX) {
-    ImPlot::SetNextPlotLimitsX(view.m_plots[i - 1]->m_xaxisRange.Min,
-                               view.m_plots[i - 1]->m_xaxisRange.Max,
-                               ImGuiCond_Always);
-  } else {
-    // also force-pause plots if overall timing is paused
-    double zeroTime = GetZeroTime() * 1.0e-6;
-    ImPlot::SetNextPlotLimitsX(
-        now - zeroTime - m_viewTime, now - zeroTime,
-        (paused || m_paused) ? ImGuiCond_Once : ImGuiCond_Always);
-  }
-
-  ImPlotAxisFlags yFlags[3] = {ImPlotAxisFlags_None,
-                               ImPlotAxisFlags_NoGridLines,
-                               ImPlotAxisFlags_NoGridLines};
-  for (int i = 0; i < 3; ++i) {
-    ImPlot::SetNextPlotLimitsY(
-        m_axisRange[i].min, m_axisRange[i].max,
-        m_axisRange[i].apply ? ImGuiCond_Always : ImGuiCond_Once, i);
-    m_axisRange[i].apply = false;
-    if (m_axisRange[i].lockMin) {
-      yFlags[i] |= ImPlotAxisFlags_LockMin;
+  if (ImPlot::BeginPlot(label, ImVec2(-1, m_height), plotFlags)) {
+    // setup legend
+    if (m_legend) {
+      ImPlotLegendFlags legendFlags =
+          (m_legendOutside ? ImPlotLegendFlags_Outside : 0) |
+          (m_legendHorizontal ? ImPlotLegendFlags_Horizontal : 0);
+      ImPlot::SetupLegend(m_legendLocation, legendFlags);
     }
-    if (m_axisRange[i].lockMax) {
-      yFlags[i] |= ImPlotAxisFlags_LockMax;
-    }
-  }
 
-  if (ImPlot::BeginPlot(
-          label, nullptr,
-          m_axisLabel[0].empty() ? nullptr : m_axisLabel[0].c_str(),
-          ImVec2(-1, m_height), m_plotFlags, ImPlotAxisFlags_None, yFlags[0],
-          yFlags[1], yFlags[2],
-          m_axisLabel[1].empty() ? nullptr : m_axisLabel[1].c_str(),
-          m_axisLabel[2].empty() ? nullptr : m_axisLabel[2].c_str())) {
+    // setup x axis
+    ImPlot::SetupAxis(ImAxis_X1, nullptr, ImPlotAxisFlags_NoMenus);
+    if (lockX) {
+      ImPlot::SetupAxisLimits(ImAxis_X1, view.m_plots[i - 1]->m_xaxisRange.Min,
+                              view.m_plots[i - 1]->m_xaxisRange.Max,
+                              ImGuiCond_Always);
+    } else {
+      // also force-pause plots if overall timing is paused
+      double zeroTime = GetZeroTime() * 1.0e-6;
+      ImPlot::SetupAxisLimits(
+          ImAxis_X1, now - zeroTime - m_viewTime, now - zeroTime,
+          (paused || m_paused) ? ImGuiCond_Once : ImGuiCond_Always);
+    }
+
+    // setup y axes
+    for (int i = 0; i < kAxisCount; ++i) {
+      if ((i == 1 && !m_yAxis2) || (i == 2 && !m_yAxis3)) {
+        continue;
+      }
+      ImPlotAxisFlags flags =
+          (m_axis[i].lockMin ? ImPlotAxisFlags_LockMin : 0) |
+          (m_axis[i].lockMax ? ImPlotAxisFlags_LockMax : 0) |
+          (m_axis[i].autoFit ? ImPlotAxisFlags_AutoFit : 0) |
+          (m_axis[i].logScale ? ImPlotAxisFlags_AutoFit : 0) |
+          (m_axis[i].invert ? ImPlotAxisFlags_Invert : 0) |
+          (m_axis[i].opposite ? ImPlotAxisFlags_Opposite : 0) |
+          (m_axis[i].gridLines ? 0 : ImPlotAxisFlags_NoGridLines) |
+          (m_axis[i].tickMarks ? 0 : ImPlotAxisFlags_NoTickMarks) |
+          (m_axis[i].tickLabels ? 0 : ImPlotAxisFlags_NoTickLabels);
+      ImPlot::SetupAxis(
+          ImAxis_Y1 + i,
+          m_axis[i].label.empty() ? nullptr : m_axis[i].label.c_str(), flags);
+      ImPlot::SetupAxisLimits(
+          ImAxis_Y1 + i, m_axis[i].min, m_axis[i].max,
+          m_axis[i].apply ? ImGuiCond_Always : ImGuiCond_Once);
+      m_axis[i].apply = false;
+    }
+
+    ImPlot::SetupFinish();
+
     for (size_t j = 0; j < m_series.size(); ++j) {
-      ImGui::PushID(j);
       switch (m_series[j]->EmitPlot(view, now, j, i)) {
         case PlotSeries::kMoveUp:
           if (j > 0) {
+            std::swap(m_seriesStorage[j - 1], m_seriesStorage[j]);
             std::swap(m_series[j - 1], m_series[j]);
           }
           break;
         case PlotSeries::kMoveDown:
           if (j < (m_series.size() - 1)) {
+            std::swap(m_seriesStorage[j], m_seriesStorage[j + 1]);
             std::swap(m_series[j], m_series[j + 1]);
           }
           break;
         case PlotSeries::kDelete:
+          m_seriesStorage.erase(m_seriesStorage.begin() + j);
           m_series.erase(m_series.begin() + j);
           break;
         default:
           break;
       }
-      ImGui::PopID();
     }
     DragDropTarget(view, i, true);
     m_xaxisRange = ImPlot::GetPlotLimits().X;
+
+    ImPlotPlot* plot = ImPlot::GetCurrentPlot();
     ImPlot::EndPlot();
+
+    // copy plot settings back to storage
+    m_legend = (plot->Flags & ImPlotFlags_NoLegend) == 0;
+    m_crosshairs = (plot->Flags & ImPlotFlags_Crosshairs) != 0;
+    m_antialiased = (plot->Flags & ImPlotFlags_AntiAliased) != 0;
+    m_legendOutside =
+        (plot->Items.Legend.Flags & ImPlotLegendFlags_Outside) != 0;
+    m_legendHorizontal =
+        (plot->Items.Legend.Flags & ImPlotLegendFlags_Horizontal) != 0;
+    m_legendLocation = plot->Items.Legend.Location;
+
+    for (int i = 0; i < kAxisCount; ++i) {
+      if ((i == 1 && !m_yAxis2) || (i == 2 && !m_yAxis3)) {
+        continue;
+      }
+      auto flags = plot->Axes[ImAxis_Y1 + i].Flags;
+      m_axis[i].lockMin = (flags & ImPlotAxisFlags_LockMin) != 0;
+      m_axis[i].lockMax = (flags & ImPlotAxisFlags_LockMax) != 0;
+      m_axis[i].autoFit = (flags & ImPlotAxisFlags_AutoFit) != 0;
+      m_axis[i].logScale = (flags & ImPlotAxisFlags_LogScale) != 0;
+      m_axis[i].invert = (flags & ImPlotAxisFlags_Invert) != 0;
+      m_axis[i].opposite = (flags & ImPlotAxisFlags_Opposite) != 0;
+      m_axis[i].gridLines = (flags & ImPlotAxisFlags_NoGridLines) == 0;
+      m_axis[i].tickMarks = (flags & ImPlotAxisFlags_NoTickMarks) == 0;
+      m_axis[i].tickLabels = (flags & ImPlotAxisFlags_NoTickLabels) == 0;
+    }
   }
 }
 
@@ -708,22 +686,22 @@
   ImGui::PushID(axis);
 
   ImGui::SetNextItemWidth(ImGui::GetFontSize() * 10);
-  ImGui::InputText("Label", &m_axisLabel[axis]);
+  ImGui::InputText("Label", &m_axis[axis].label);
   ImGui::SetNextItemWidth(ImGui::GetFontSize() * 3.5);
-  ImGui::InputDouble("Min", &m_axisRange[axis].min, 0, 0, "%.3f");
+  ImGui::InputDouble("Min", &m_axis[axis].min, 0, 0, "%.3f");
   ImGui::SameLine();
   ImGui::SetNextItemWidth(ImGui::GetFontSize() * 3.5);
-  ImGui::InputDouble("Max", &m_axisRange[axis].max, 0, 0, "%.3f");
+  ImGui::InputDouble("Max", &m_axis[axis].max, 0, 0, "%.3f");
   ImGui::SameLine();
   if (ImGui::Button("Apply")) {
-    m_axisRange[axis].apply = true;
+    m_axis[axis].apply = true;
   }
 
   ImGui::TextUnformatted("Lock Axis");
   ImGui::SameLine();
-  ImGui::Checkbox("Min##minlock", &m_axisRange[axis].lockMin);
+  ImGui::Checkbox("Min##minlock", &m_axis[axis].lockMin);
   ImGui::SameLine();
-  ImGui::Checkbox("Max##maxlock", &m_axisRange[axis].lockMax);
+  ImGui::Checkbox("Max##maxlock", &m_axis[axis].lockMax);
 
   ImGui::PopID();
   ImGui::Unindent();
@@ -734,18 +712,17 @@
   ImGui::InputText("##editname", &m_name);
   ImGui::Checkbox("Visible", &m_visible);
   ImGui::Checkbox("Show Pause Button", &m_showPause);
-  ImGui::CheckboxFlags("Hide Legend", &m_plotFlags, ImPlotFlags_NoLegend);
   if (i != 0) {
     ImGui::Checkbox("Lock X-axis to previous plot", &m_lockPrevX);
   }
   ImGui::TextUnformatted("Primary Y-Axis");
   EmitSettingsLimits(0);
-  ImGui::CheckboxFlags("2nd Y-Axis", &m_plotFlags, ImPlotFlags_YAxis2);
-  if ((m_plotFlags & ImPlotFlags_YAxis2) != 0) {
+  ImGui::Checkbox("2nd Y-Axis", &m_yAxis2);
+  if (m_yAxis2) {
     EmitSettingsLimits(1);
   }
-  ImGui::CheckboxFlags("3rd Y-Axis", &m_plotFlags, ImPlotFlags_YAxis3);
-  if ((m_plotFlags & ImPlotFlags_YAxis3) != 0) {
+  ImGui::Checkbox("3rd Y-Axis", &m_yAxis3);
+  if (m_yAxis3) {
     EmitSettingsLimits(2);
   }
   ImGui::SetNextItemWidth(ImGui::GetFontSize() * 6);
@@ -778,10 +755,20 @@
   return height;
 }
 
+PlotView::PlotView(PlotProvider* provider, Storage& storage)
+    : m_provider{provider}, m_plotsStorage{storage.GetChildArray("plots")} {
+  // loop over plots
+  for (auto&& v : m_plotsStorage) {
+    // create plot
+    m_plots.emplace_back(std::make_unique<Plot>(*v));
+  }
+}
+
 void PlotView::Display() {
   if (ImGui::BeginPopupContextItem()) {
     if (ImGui::Button("Add plot")) {
-      m_plots.emplace_back(std::make_unique<Plot>());
+      m_plotsStorage.emplace_back(std::make_unique<Storage>());
+      m_plots.emplace_back(std::make_unique<Plot>(*m_plotsStorage.back()));
     }
 
     for (size_t i = 0; i < m_plots.size(); ++i) {
@@ -813,6 +800,7 @@
       if (open) {
         if (ImGui::Button("Move Up")) {
           if (i > 0) {
+            std::swap(m_plotsStorage[i - 1], m_plotsStorage[i]);
             std::swap(m_plots[i - 1], plot);
           }
         }
@@ -820,12 +808,14 @@
         ImGui::SameLine();
         if (ImGui::Button("Move Down")) {
           if (i < (m_plots.size() - 1)) {
+            std::swap(m_plotsStorage[i], m_plotsStorage[i + 1]);
             std::swap(plot, m_plots[i + 1]);
           }
         }
 
         ImGui::SameLine();
         if (ImGui::Button("Delete")) {
+          m_plotsStorage.erase(m_plotsStorage.begin() + i);
           m_plots.erase(m_plots.begin() + i);
           ImGui::PopID();
           continue;
@@ -842,7 +832,8 @@
 
   if (m_plots.empty()) {
     if (ImGui::Button("Add plot")) {
-      m_plots.emplace_back(std::make_unique<Plot>());
+      m_plotsStorage.emplace_back(std::make_unique<Storage>());
+      m_plots.emplace_back(std::make_unique<Plot>(*m_plotsStorage.back()));
     }
 
     // Make "add plot" button a DND target for Plot
@@ -889,10 +880,21 @@
     if (fromIndex == toIndex) {
       return;
     }
+
+    auto st = std::move(m_plotsStorage[fromIndex]);
+    m_plotsStorage.insert(m_plotsStorage.begin() + toIndex, std::move(st));
+    m_plotsStorage.erase(m_plotsStorage.begin() + fromIndex +
+                         (fromIndex > toIndex ? 1 : 0));
+
     auto val = std::move(m_plots[fromIndex]);
     m_plots.insert(m_plots.begin() + toIndex, std::move(val));
     m_plots.erase(m_plots.begin() + fromIndex + (fromIndex > toIndex ? 1 : 0));
   } else {
+    auto st = std::move(fromView->m_plotsStorage[fromIndex]);
+    m_plotsStorage.insert(m_plotsStorage.begin() + toIndex, std::move(st));
+    fromView->m_plotsStorage.erase(fromView->m_plotsStorage.begin() +
+                                   fromIndex);
+
     auto val = std::move(fromView->m_plots[fromIndex]);
     m_plots.insert(m_plots.begin() + toIndex, std::move(val));
     fromView->m_plots.erase(fromView->m_plots.begin() + fromIndex);
@@ -905,6 +907,13 @@
   if (fromView == this && fromPlotIndex == toPlotIndex) {
     // need to handle this specially as the index of the old location changes
     if (fromSeriesIndex != toSeriesIndex) {
+      auto& seriesStorage = m_plots[fromPlotIndex]->m_seriesStorage;
+      auto st = std::move(seriesStorage[fromSeriesIndex]);
+      seriesStorage.insert(seriesStorage.begin() + toSeriesIndex,
+                           std::move(st));
+      seriesStorage.erase(seriesStorage.begin() + fromSeriesIndex +
+                          (fromSeriesIndex > toSeriesIndex ? 1 : 0));
+
       auto& plotSeries = m_plots[fromPlotIndex]->m_series;
       auto val = std::move(plotSeries[fromSeriesIndex]);
       // only set Y-axis if actually set
@@ -920,34 +929,53 @@
     auto& toPlot = *m_plots[toPlotIndex];
     // always set Y-axis if moving plots
     fromPlot.m_series[fromSeriesIndex]->SetYAxis(yAxis == -1 ? 0 : yAxis);
+
+    toPlot.m_seriesStorage.insert(
+        toPlot.m_seriesStorage.begin() + toSeriesIndex,
+        std::move(fromPlot.m_seriesStorage[fromSeriesIndex]));
+    fromPlot.m_seriesStorage.erase(fromPlot.m_seriesStorage.begin() +
+                                   fromSeriesIndex);
+
     toPlot.m_series.insert(toPlot.m_series.begin() + toSeriesIndex,
                            std::move(fromPlot.m_series[fromSeriesIndex]));
     fromPlot.m_series.erase(fromPlot.m_series.begin() + fromSeriesIndex);
   }
 }
 
-PlotProvider::PlotProvider(std::string_view iniName)
-    : WindowManager{fmt::format("{}Window", iniName)},
-      m_plotSaver{iniName, this, false},
-      m_seriesSaver{fmt::format("{}Series", iniName), this, true} {}
+PlotProvider::PlotProvider(Storage& storage) : WindowManager{storage} {
+  storage.SetCustomApply([this] {
+    // loop over windows
+    for (auto&& windowkv : m_storage.GetChildren()) {
+      // get or create window
+      auto win = GetOrAddWindow(windowkv.key(), true);
+      if (!win) {
+        continue;
+      }
 
-PlotProvider::~PlotProvider() = default;
-
-void PlotProvider::GlobalInit() {
-  WindowManager::GlobalInit();
-  wpi::gui::AddInit([this] {
-    m_plotSaver.Initialize();
-    m_seriesSaver.Initialize();
+      // get or create view
+      auto view = static_cast<PlotView*>(win->GetView());
+      if (!view) {
+        win->SetView(std::make_unique<PlotView>(this, windowkv.value()));
+        view = static_cast<PlotView*>(win->GetView());
+      }
+    }
+  });
+  storage.SetCustomClear([this] {
+    EraseWindows();
+    m_storage.EraseChildren();
   });
 }
 
+PlotProvider::~PlotProvider() = default;
+
 void PlotProvider::DisplayMenu() {
+  // use index-based loop due to possible RemoveWindow call
   for (size_t i = 0; i < m_windows.size(); ++i) {
     m_windows[i]->DisplayMenuItem();
     // provide method to destroy the plot window
     if (ImGui::BeginPopupContextItem()) {
       if (ImGui::Selectable("Destroy Plot Window")) {
-        m_windows.erase(m_windows.begin() + i);
+        RemoveWindow(i);
         ImGui::CloseCurrentPopup();
       }
       ImGui::EndPopup();
@@ -971,105 +999,9 @@
         break;
       }
     }
-    if (auto win = AddWindow(id, std::make_unique<PlotView>(this))) {
+    if (auto win = AddWindow(
+            id, std::make_unique<PlotView>(this, m_storage.GetChild(id)))) {
       win->SetDefaultSize(700, 400);
     }
   }
 }
-
-void PlotProvider::DisplayWindows() {
-  // create views if not already created
-  for (auto&& window : m_windows) {
-    if (!window->HasView()) {
-      window->SetView(std::make_unique<PlotView>(this));
-    }
-  }
-  WindowManager::DisplayWindows();
-}
-
-PlotProvider::IniSaver::IniSaver(std::string_view typeName,
-                                 PlotProvider* provider, bool forSeries)
-    : IniSaverBase{typeName}, m_provider{provider}, m_forSeries{forSeries} {}
-
-void* PlotProvider::IniSaver::IniReadOpen(const char* name) {
-  auto [viewId, plotNumStr] = wpi::split(name, '#');
-  std::string_view seriesId;
-  if (m_forSeries) {
-    std::tie(plotNumStr, seriesId) = wpi::split(plotNumStr, '#');
-    if (seriesId.empty()) {
-      return nullptr;
-    }
-  }
-  unsigned int plotNum;
-  if (auto plotNumOpt = wpi::parse_integer<unsigned int>(plotNumStr, 10)) {
-    plotNum = plotNumOpt.value();
-  } else {
-    return nullptr;
-  }
-
-  // get or create window
-  auto win = m_provider->GetOrAddWindow(viewId, true);
-  if (!win) {
-    return nullptr;
-  }
-
-  // get or create view
-  auto view = static_cast<PlotView*>(win->GetView());
-  if (!view) {
-    win->SetView(std::make_unique<PlotView>(m_provider));
-    view = static_cast<PlotView*>(win->GetView());
-  }
-
-  // get or create plot
-  if (view->m_plots.size() <= plotNum) {
-    view->m_plots.resize(plotNum + 1);
-  }
-  auto& plot = view->m_plots[plotNum];
-  if (!plot) {
-    plot = std::make_unique<Plot>();
-  }
-
-  // early exit for plot data
-  if (!m_forSeries) {
-    return plot.get();
-  }
-
-  // get or create series
-  return plot->m_series.emplace_back(std::make_unique<PlotSeries>(seriesId))
-      .get();
-}
-
-void PlotProvider::IniSaver::IniReadLine(void* entry, const char* line) {
-  auto [name, value] = wpi::split(line, '=');
-  name = wpi::trim(name);
-  value = wpi::trim(value);
-  if (m_forSeries) {
-    static_cast<PlotSeries*>(entry)->ReadIni(name, value);
-  } else {
-    static_cast<Plot*>(entry)->ReadIni(name, value);
-  }
-}
-
-void PlotProvider::IniSaver::IniWriteAll(ImGuiTextBuffer* out_buf) {
-  for (auto&& win : m_provider->m_windows) {
-    auto view = static_cast<PlotView*>(win->GetView());
-    auto id = win->GetId();
-    for (size_t i = 0; i < view->m_plots.size(); ++i) {
-      if (m_forSeries) {
-        // Loop over series
-        for (auto&& series : view->m_plots[i]->m_series) {
-          out_buf->appendf("[%s][%s#%d#%s]\n", GetTypeName(), id.data(),
-                           static_cast<int>(i), series->GetId().c_str());
-          series->WriteIni(out_buf);
-          out_buf->append("\n");
-        }
-      } else {
-        // Just the plot
-        out_buf->appendf("[%s][%s#%d]\n", GetTypeName(), id.data(),
-                         static_cast<int>(i));
-        view->m_plots[i]->WriteIni(out_buf);
-        out_buf->append("\n");
-      }
-    }
-  }
-}
diff --git a/glass/src/lib/native/cpp/support/ColorSetting.cpp b/glass/src/lib/native/cpp/support/ColorSetting.cpp
new file mode 100644
index 0000000..9f20a0b
--- /dev/null
+++ b/glass/src/lib/native/cpp/support/ColorSetting.cpp
@@ -0,0 +1,11 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "glass/support/ColorSetting.h"
+
+using namespace glass;
+
+ColorSetting::ColorSetting(std::vector<float>& color) : m_color{color} {
+  m_color.resize(4);
+}
diff --git a/glass/src/lib/native/cpp/support/EnumSetting.cpp b/glass/src/lib/native/cpp/support/EnumSetting.cpp
new file mode 100644
index 0000000..848b588
--- /dev/null
+++ b/glass/src/lib/native/cpp/support/EnumSetting.cpp
@@ -0,0 +1,40 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "glass/support/EnumSetting.h"
+
+#include <imgui.h>
+
+using namespace glass;
+
+EnumSetting::EnumSetting(std::string& str, int defaultValue,
+                         std::initializer_list<const char*> choices)
+    : m_str{str}, m_choices{choices}, m_value{defaultValue} {
+  // override default value if str is one of the choices
+  int i = 0;
+  for (auto choice : choices) {
+    if (str == choice) {
+      m_value = i;
+      break;
+    }
+    ++i;
+  }
+}
+
+void EnumSetting::SetValue(int value) {
+  m_value = value;
+  m_str = m_choices[m_value];
+}
+
+bool EnumSetting::Combo(const char* label, int numOptions,
+                        int popup_max_height_in_items) {
+  if (ImGui::Combo(
+          label, &m_value, m_choices.data(),
+          numOptions < 0 ? m_choices.size() : static_cast<size_t>(numOptions),
+          popup_max_height_in_items)) {
+    m_str = m_choices[m_value];  // update stored string
+    return true;
+  }
+  return false;
+}
diff --git a/glass/src/lib/native/cpp/support/ExtraGuiWidgets.cpp b/glass/src/lib/native/cpp/support/ExtraGuiWidgets.cpp
index 2af6e5e..56f4e77 100644
--- a/glass/src/lib/native/cpp/support/ExtraGuiWidgets.cpp
+++ b/glass/src/lib/native/cpp/support/ExtraGuiWidgets.cpp
@@ -160,17 +160,17 @@
 bool HeaderDeleteButton(const char* label) {
   ImGuiWindow* window = ImGui::GetCurrentWindow();
   ImGuiContext& g = *GImGui;
-  ImGuiLastItemDataBackup last_item_backup;
+  ImGuiLastItemData last_item_backup = g.LastItemData;
   ImGuiID id = window->GetID(label);
   float button_size = g.FontSize;
-  float button_x = ImMax(window->DC.LastItemRect.Min.x,
-                         window->DC.LastItemRect.Max.x -
-                             g.Style.FramePadding.x * 2.0f - button_size);
-  float button_y = window->DC.LastItemRect.Min.y;
+  float button_x = ImMax(
+      g.LastItemData.Rect.Min.x,
+      g.LastItemData.Rect.Max.x - g.Style.FramePadding.x * 2.0f - button_size);
+  float button_y = g.LastItemData.Rect.Min.y;
   bool rv = DeleteButton(
       window->GetID(reinterpret_cast<void*>(static_cast<intptr_t>(id) + 1)),
       ImVec2(button_x, button_y));
-  last_item_backup.Restore();
+  g.LastItemData = last_item_backup;
   return rv;
 }
 
diff --git a/glass/src/lib/native/cpp/support/IniSaverBase.cpp b/glass/src/lib/native/cpp/support/IniSaverBase.cpp
deleted file mode 100644
index ae8d811..0000000
--- a/glass/src/lib/native/cpp/support/IniSaverBase.cpp
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#include "glass/support/IniSaverBase.h"
-
-#include <imgui_internal.h>
-
-using namespace glass;
-
-namespace {
-class ImGuiSaver : public IniSaverBackend {
- public:
-  void Register(IniSaverBase* iniSaver) override;
-  void Unregister(IniSaverBase* iniSaver) override;
-};
-}  // namespace
-
-void ImGuiSaver::Register(IniSaverBase* iniSaver) {
-  // hook ini handler to save settings
-  ImGuiSettingsHandler iniHandler;
-  iniHandler.TypeName = iniSaver->GetTypeName();
-  iniHandler.TypeHash = ImHashStr(iniHandler.TypeName);
-  iniHandler.ReadOpenFn = [](ImGuiContext* ctx, ImGuiSettingsHandler* handler,
-                             const char* name) {
-    return static_cast<IniSaverBase*>(handler->UserData)->IniReadOpen(name);
-  };
-  iniHandler.ReadLineFn = [](ImGuiContext* ctx, ImGuiSettingsHandler* handler,
-                             void* entry, const char* line) {
-    static_cast<IniSaverBase*>(handler->UserData)->IniReadLine(entry, line);
-  };
-  iniHandler.WriteAllFn = [](ImGuiContext* ctx, ImGuiSettingsHandler* handler,
-                             ImGuiTextBuffer* out_buf) {
-    static_cast<IniSaverBase*>(handler->UserData)->IniWriteAll(out_buf);
-  };
-  iniHandler.UserData = iniSaver;
-  ImGui::GetCurrentContext()->SettingsHandlers.push_back(iniHandler);
-}
-
-void ImGuiSaver::Unregister(IniSaverBase* iniSaver) {
-  if (auto ctx = ImGui::GetCurrentContext()) {
-    auto& handlers = ctx->SettingsHandlers;
-    for (auto it = handlers.begin(), end = handlers.end(); it != end; ++it) {
-      if (it->UserData == iniSaver) {
-        handlers.erase(it);
-        return;
-      }
-    }
-  }
-}
-
-static ImGuiSaver* GetSaverInstance() {
-  static ImGuiSaver* inst = new ImGuiSaver;
-  return inst;
-}
-
-IniSaverBase::IniSaverBase(std::string_view typeName, IniSaverBackend* backend)
-    : m_typeName(typeName), m_backend{backend ? backend : GetSaverInstance()} {}
-
-IniSaverBase::~IniSaverBase() {
-  m_backend->Unregister(this);
-}
diff --git a/glass/src/lib/native/cpp/support/IniSaverInfo.cpp b/glass/src/lib/native/cpp/support/IniSaverInfo.cpp
deleted file mode 100644
index 6525e8e..0000000
--- a/glass/src/lib/native/cpp/support/IniSaverInfo.cpp
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#include "glass/support/IniSaverInfo.h"
-
-#include <cstdio>
-#include <cstring>
-
-#include <imgui_internal.h>
-#include <wpi/StringExtras.h>
-
-using namespace glass;
-
-void NameInfo::SetName(std::string_view name) {
-  size_t len = (std::min)(name.size(), sizeof(m_name) - 1);
-  std::memcpy(m_name, name.data(), len);
-  m_name[len] = '\0';
-}
-
-void NameInfo::GetName(char* buf, size_t size, const char* defaultName) const {
-  if (m_name[0] != '\0') {
-    std::snprintf(buf, size, "%s", m_name);
-  } else {
-    std::snprintf(buf, size, "%s", defaultName);
-  }
-}
-
-void NameInfo::GetName(char* buf, size_t size, const char* defaultName,
-                       int index) const {
-  if (m_name[0] != '\0') {
-    std::snprintf(buf, size, "%s [%d]", m_name, index);
-  } else {
-    std::snprintf(buf, size, "%s[%d]", defaultName, index);
-  }
-}
-
-void NameInfo::GetName(char* buf, size_t size, const char* defaultName,
-                       int index, int index2) const {
-  if (m_name[0] != '\0') {
-    std::snprintf(buf, size, "%s [%d,%d]", m_name, index, index2);
-  } else {
-    std::snprintf(buf, size, "%s[%d,%d]", defaultName, index, index2);
-  }
-}
-
-void NameInfo::GetLabel(char* buf, size_t size, const char* defaultName) const {
-  if (m_name[0] != '\0') {
-    std::snprintf(buf, size, "%s###Name%s", m_name, defaultName);
-  } else {
-    std::snprintf(buf, size, "%s###Name%s", defaultName, defaultName);
-  }
-}
-
-void NameInfo::GetLabel(char* buf, size_t size, const char* defaultName,
-                        int index) const {
-  if (m_name[0] != '\0') {
-    std::snprintf(buf, size, "%s [%d]###Name%d", m_name, index, index);
-  } else {
-    std::snprintf(buf, size, "%s[%d]###Name%d", defaultName, index, index);
-  }
-}
-
-void NameInfo::GetLabel(char* buf, size_t size, const char* defaultName,
-                        int index, int index2) const {
-  if (m_name[0] != '\0') {
-    std::snprintf(buf, size, "%s [%d,%d]###Name%d", m_name, index, index2,
-                  index);
-  } else {
-    std::snprintf(buf, size, "%s[%d,%d]###Name%d", defaultName, index, index2,
-                  index);
-  }
-}
-
-bool NameInfo::ReadIni(std::string_view name, std::string_view value) {
-  if (name != "name") {
-    return false;
-  }
-  size_t len = (std::min)(value.size(), sizeof(m_name) - 1);
-  std::memcpy(m_name, value.data(), len);
-  m_name[len] = '\0';
-  return true;
-}
-
-void NameInfo::WriteIni(ImGuiTextBuffer* out) {
-  out->appendf("name=%s\n", m_name);
-}
-
-void NameInfo::PushEditNameId(int index) {
-  char id[64];
-  std::snprintf(id, sizeof(id), "Name%d", index);
-  ImGui::PushID(id);
-}
-
-void NameInfo::PushEditNameId(const char* name) {
-  char id[128];
-  std::snprintf(id, sizeof(id), "Name%s", name);
-  ImGui::PushID(id);
-}
-
-bool NameInfo::PopupEditName(int index) {
-  bool rv = false;
-  char id[64];
-  std::snprintf(id, sizeof(id), "Name%d", index);
-  if (ImGui::BeginPopupContextItem(id)) {
-    ImGui::Text("Edit name:");
-    if (InputTextName("##edit")) {
-      rv = true;
-    }
-    if (ImGui::Button("Close") || ImGui::IsKeyPressedMap(ImGuiKey_Enter) ||
-        ImGui::IsKeyPressedMap(ImGuiKey_KeyPadEnter)) {
-      ImGui::CloseCurrentPopup();
-    }
-    ImGui::EndPopup();
-  }
-  return rv;
-}
-
-bool NameInfo::PopupEditName(const char* name) {
-  bool rv = false;
-  char id[128];
-  std::snprintf(id, sizeof(id), "Name%s", name);
-  if (ImGui::BeginPopupContextItem(id)) {
-    ImGui::Text("Edit name:");
-    if (InputTextName("##edit")) {
-      rv = true;
-    }
-    if (ImGui::Button("Close") || ImGui::IsKeyPressedMap(ImGuiKey_Enter) ||
-        ImGui::IsKeyPressedMap(ImGuiKey_KeyPadEnter)) {
-      ImGui::CloseCurrentPopup();
-    }
-    ImGui::EndPopup();
-  }
-  return rv;
-}
-
-bool NameInfo::InputTextName(const char* label_id, ImGuiInputTextFlags flags) {
-  return ImGui::InputText(label_id, m_name, sizeof(m_name), flags);
-}
-
-bool OpenInfo::ReadIni(std::string_view name, std::string_view value) {
-  if (name != "open") {
-    return false;
-  }
-  if (auto num = wpi::parse_integer<int>(value, 10)) {
-    m_open = num.value();
-  }
-  return true;
-}
-
-void OpenInfo::WriteIni(ImGuiTextBuffer* out) {
-  out->appendf("open=%d\n", m_open ? 1 : 0);
-}
-
-bool NameOpenInfo::ReadIni(std::string_view name, std::string_view value) {
-  if (NameInfo::ReadIni(name, value)) {
-    return true;
-  }
-  if (OpenInfo::ReadIni(name, value)) {
-    return true;
-  }
-  return false;
-}
-
-void NameOpenInfo::WriteIni(ImGuiTextBuffer* out) {
-  NameInfo::WriteIni(out);
-  OpenInfo::WriteIni(out);
-}
diff --git a/glass/src/lib/native/cpp/support/NameSetting.cpp b/glass/src/lib/native/cpp/support/NameSetting.cpp
new file mode 100644
index 0000000..1dc1d20
--- /dev/null
+++ b/glass/src/lib/native/cpp/support/NameSetting.cpp
@@ -0,0 +1,123 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "glass/support/NameSetting.h"
+
+#include <cstdio>
+#include <cstring>
+
+#include <imgui_internal.h>
+#include <imgui_stdlib.h>
+#include <wpi/StringExtras.h>
+
+using namespace glass;
+
+void NameSetting::GetName(char* buf, size_t size,
+                          const char* defaultName) const {
+  if (!m_name.empty()) {
+    std::snprintf(buf, size, "%s", m_name.c_str());
+  } else {
+    std::snprintf(buf, size, "%s", defaultName);
+  }
+}
+
+void NameSetting::GetName(char* buf, size_t size, const char* defaultName,
+                          int index) const {
+  if (!m_name.empty()) {
+    std::snprintf(buf, size, "%s [%d]", m_name.c_str(), index);
+  } else {
+    std::snprintf(buf, size, "%s[%d]", defaultName, index);
+  }
+}
+
+void NameSetting::GetName(char* buf, size_t size, const char* defaultName,
+                          int index, int index2) const {
+  if (!m_name.empty()) {
+    std::snprintf(buf, size, "%s [%d,%d]", m_name.c_str(), index, index2);
+  } else {
+    std::snprintf(buf, size, "%s[%d,%d]", defaultName, index, index2);
+  }
+}
+
+void NameSetting::GetLabel(char* buf, size_t size,
+                           const char* defaultName) const {
+  if (!m_name.empty()) {
+    std::snprintf(buf, size, "%s###Name%s", m_name.c_str(), defaultName);
+  } else {
+    std::snprintf(buf, size, "%s###Name%s", defaultName, defaultName);
+  }
+}
+
+void NameSetting::GetLabel(char* buf, size_t size, const char* defaultName,
+                           int index) const {
+  if (!m_name.empty()) {
+    std::snprintf(buf, size, "%s [%d]###Name%d", m_name.c_str(), index, index);
+  } else {
+    std::snprintf(buf, size, "%s[%d]###Name%d", defaultName, index, index);
+  }
+}
+
+void NameSetting::GetLabel(char* buf, size_t size, const char* defaultName,
+                           int index, int index2) const {
+  if (!m_name.empty()) {
+    std::snprintf(buf, size, "%s [%d,%d]###Name%d", m_name.c_str(), index,
+                  index2, index);
+  } else {
+    std::snprintf(buf, size, "%s[%d,%d]###Name%d", defaultName, index, index2,
+                  index);
+  }
+}
+
+void NameSetting::PushEditNameId(int index) {
+  char id[64];
+  std::snprintf(id, sizeof(id), "Name%d", index);
+  ImGui::PushID(id);
+}
+
+void NameSetting::PushEditNameId(const char* name) {
+  char id[128];
+  std::snprintf(id, sizeof(id), "Name%s", name);
+  ImGui::PushID(id);
+}
+
+bool NameSetting::PopupEditName(int index) {
+  bool rv = false;
+  char id[64];
+  std::snprintf(id, sizeof(id), "Name%d", index);
+  if (ImGui::BeginPopupContextItem(id)) {
+    ImGui::Text("Edit name:");
+    if (InputTextName("##edit")) {
+      rv = true;
+    }
+    if (ImGui::Button("Close") || ImGui::IsKeyPressedMap(ImGuiKey_Enter) ||
+        ImGui::IsKeyPressedMap(ImGuiKey_KeyPadEnter)) {
+      ImGui::CloseCurrentPopup();
+    }
+    ImGui::EndPopup();
+  }
+  return rv;
+}
+
+bool NameSetting::PopupEditName(const char* name) {
+  bool rv = false;
+  char id[128];
+  std::snprintf(id, sizeof(id), "Name%s", name);
+  if (ImGui::BeginPopupContextItem(id)) {
+    ImGui::Text("Edit name:");
+    if (InputTextName("##edit")) {
+      rv = true;
+    }
+    if (ImGui::Button("Close") || ImGui::IsKeyPressedMap(ImGuiKey_Enter) ||
+        ImGui::IsKeyPressedMap(ImGuiKey_KeyPadEnter)) {
+      ImGui::CloseCurrentPopup();
+    }
+    ImGui::EndPopup();
+  }
+  return rv;
+}
+
+bool NameSetting::InputTextName(const char* label_id,
+                                ImGuiInputTextFlags flags) {
+  return ImGui::InputText(label_id, &m_name, flags);
+}
diff --git a/glass/src/lib/native/include/glass/Context.h b/glass/src/lib/native/include/glass/Context.h
index 62b0a33..14ade2c 100644
--- a/glass/src/lib/native/include/glass/Context.h
+++ b/glass/src/lib/native/include/glass/Context.h
@@ -4,17 +4,16 @@
 
 #pragma once
 
-#include <memory>
+#include <functional>
 #include <string>
 #include <string_view>
-#include <utility>
-#include <vector>
 
 #include <imgui.h>
 
 namespace glass {
 
-struct Context;
+class Context;
+class Storage;
 
 Context* CreateContext();
 void DestroyContext(Context* ctx = nullptr);
@@ -32,86 +31,135 @@
 uint64_t GetZeroTime();
 
 /**
- * Storage provides both persistent and non-persistent key/value storage for
- * widgets.
- *
- * Keys are always strings.  The storage also provides non-persistent arbitrary
- * data storage (via std::shared_ptr<void>).
- *
- * Storage is automatically indexed internally by the ID stack.  Note it is
- * necessary to use the glass wrappers for PushID et al to preserve naming in
- * the save file (unnamed values are still stored, but this is non-ideal for
- * users trying to hand-edit the save file).
+ * Resets the workspace (all storage except window storage).
+ * Operates effectively like calling LoadStorage() on a path with no existing
+ * storage files. Note this will result in auto-saving of the reset state to
+ * storage.
  */
-class Storage {
- public:
-  struct Value {
-    Value() = default;
-    explicit Value(std::string_view str) : stringVal{str} {}
+void WorkspaceReset();
 
-    enum Type { kNone, kInt, kInt64, kBool, kFloat, kDouble, kString };
-    Type type = kNone;
-    union {
-      int intVal;
-      int64_t int64Val;
-      bool boolVal;
-      float floatVal;
-      double doubleVal;
-    };
-    std::string stringVal;
-  };
+/**
+ * Adds function to be called during workspace (storage) initialization/load.
+ * This should set up any initial default state, restore stored
+ * settings/windows, etc. This will be called after the storage is initialized.
+ * This must be called prior to WorkspaceInit() for proper automatic startup
+ * loading.
+ *
+ * @param init initialization function
+ */
+void AddWorkspaceInit(std::function<void()> init);
 
-  int GetInt(std::string_view key, int defaultVal = 0) const;
-  int64_t GetInt64(std::string_view key, int64_t defaultVal = 0) const;
-  bool GetBool(std::string_view key, bool defaultVal = false) const;
-  float GetFloat(std::string_view key, float defaultVal = 0.0f) const;
-  double GetDouble(std::string_view key, double defaultVal = 0.0) const;
-  std::string GetString(std::string_view key,
-                        std::string_view defaultVal = {}) const;
+/**
+ * Adds function to be called during workspace (storage) reset.  This should
+ * bring back the state to startup state (e.g. remove any storage references,
+ * destroy windows, etc). This will be called prior to the storage being
+ * destroyed.
+ *
+ * @param reset reset function
+ */
+void AddWorkspaceReset(std::function<void()> reset);
 
-  void SetInt(std::string_view key, int val);
-  void SetInt64(std::string_view key, int64_t val);
-  void SetBool(std::string_view key, bool val);
-  void SetFloat(std::string_view key, float val);
-  void SetDouble(std::string_view key, double val);
-  void SetString(std::string_view key, std::string_view val);
+/**
+ * Sets storage load and auto-save name.
+ * Call this prior to calling wpi::gui::Initialize() for automatic startup
+ * loading.
+ *
+ * @param name base name, suffix will be generated
+ */
+void SetStorageName(std::string_view name);
 
-  int* GetIntRef(std::string_view key, int defaultVal = 0);
-  int64_t* GetInt64Ref(std::string_view key, int64_t defaultVal = 0);
-  bool* GetBoolRef(std::string_view key, bool defaultVal = false);
-  float* GetFloatRef(std::string_view key, float defaultVal = 0.0f);
-  double* GetDoubleRef(std::string_view key, double defaultVal = 0.0);
-  std::string* GetStringRef(std::string_view key,
-                            std::string_view defaultVal = {});
+/**
+ * Sets storage load and auto-save directory. For more customized behavior, set
+ * Context::storageLoadPath and Context::storageAutoSavePath directly.
+ * Call this prior to calling wpi::gui::Initialize() for automatic startup
+ * loading.
+ *
+ * @param dir path to directory
+ */
+void SetStorageDir(std::string_view dir);
 
-  Value& GetValue(std::string_view key);
+/**
+ * Gets storage auto-save directory.
+ *
+ * @return Path to directory
+ */
+std::string GetStorageDir();
 
-  void SetData(std::shared_ptr<void>&& data) { m_data = std::move(data); }
+/**
+ * Explicitly load storage. Set Context::storageLoadDir prior to calling
+ * wpi::gui::Initialize() for automatic startup loading.
+ *
+ * Non-empty root names are not loaded unless GetStorageRoot() is called during
+ * initialization (or before this function is called).
+ *
+ * @param dir path to directory
+ */
+bool LoadStorage(std::string_view dir);
 
-  template <typename T>
-  T* GetData() const {
-    return static_cast<T*>(m_data.get());
-  }
+/**
+ * Save storage to automatic on-change save location.
+ */
+bool SaveStorage();
 
-  Storage() = default;
-  Storage(const Storage&) = delete;
-  Storage& operator=(const Storage&) = delete;
+/**
+ * Explicitly save storage. Set Context::storageAutoSaveDir prior to calling
+ * wpi::gui::Initialize() for automatic on-change saving.
+ *
+ * @param dir path to directory
+ */
+bool SaveStorage(std::string_view dir);
 
-  std::vector<std::string>& GetKeys() { return m_keys; }
-  const std::vector<std::string>& GetKeys() const { return m_keys; }
-  std::vector<std::unique_ptr<Value>>& GetValues() { return m_values; }
-  const std::vector<std::unique_ptr<Value>>& GetValues() const {
-    return m_values;
-  }
+/**
+ * Gets the storage root for the current ID stack (e.g. the last call to
+ * ResetStorageStack).
+ *
+ * @return Storage object
+ */
+Storage& GetCurStorageRoot();
 
- private:
-  mutable std::vector<std::string> m_keys;
-  mutable std::vector<std::unique_ptr<Value>> m_values;
-  std::shared_ptr<void> m_data;
-};
+/**
+ * Gets an arbitrary storage root.
+ *
+ * Non-empty root names are saved but not loaded unless GetStorageRoot()
+ * is called during initialization (or before LoadStorage is called).
+ *
+ * @param rootName root name
+ * @return Storage object
+ */
+Storage& GetStorageRoot(std::string_view rootName = {});
 
+/**
+ * Resets storage stack.  Should only be called at top level.
+ *
+ * @param rootName root name
+ */
+void ResetStorageStack(std::string_view rootName = {});
+
+/**
+ * Gets the storage object for the current point in the ID stack.
+ *
+ * @return Storage object
+ */
 Storage& GetStorage();
-Storage& GetStorage(std::string_view id);
+
+/**
+ * Pushes label/ID onto the storage stack, without pushing the imgui ID stack.
+ *
+ * @param label_id label or label###id
+ */
+void PushStorageStack(std::string_view label_id);
+
+/**
+ * Pushes specific storage onto the storage stack.
+ *
+ * @param storage storage
+ */
+void PushStorageStack(Storage& storage);
+
+/**
+ * Pops storage stack, without popping the imgui ID stack.
+ */
+void PopStorageStack();
 
 bool Begin(const char* name, bool* p_open = nullptr,
            ImGuiWindowFlags flags = 0);
diff --git a/glass/src/lib/native/include/glass/ContextInternal.h b/glass/src/lib/native/include/glass/ContextInternal.h
index 39e54f3..7556c70 100644
--- a/glass/src/lib/native/include/glass/ContextInternal.h
+++ b/glass/src/lib/native/include/glass/ContextInternal.h
@@ -6,42 +6,40 @@
 
 #include <stdint.h>
 
+#include <functional>
 #include <memory>
+#include <string>
+#include <vector>
 
-#include <imgui.h>
-#include <wpi/SmallString.h>
 #include <wpi/SmallVector.h>
 #include <wpi/StringMap.h>
 
 #include "glass/Context.h"
-#include "glass/support/IniSaverInfo.h"
-#include "glass/support/IniSaverString.h"
+#include "glass/Storage.h"
 
 namespace glass {
 
 class DataSource;
 
-class DataSourceName {
+class Context {
  public:
-  DataSourceName() = default;
-  explicit DataSourceName(DataSource* source) : source{source} {}
+  Context();
+  Context(const Context&) = delete;
+  Context& operator=(const Context&) = delete;
+  ~Context();
 
-  bool ReadIni(std::string_view name_, std::string_view value) {
-    return name->ReadIni(name_, value);
-  }
-  void WriteIni(ImGuiTextBuffer* out) { name->WriteIni(out); }
-
-  std::unique_ptr<NameInfo> name{new NameInfo};
-  DataSource* source = nullptr;
-};
-
-struct Context {
-  wpi::SmallString<128> curId;
-  wpi::SmallVector<size_t, 32> idStack;
-  wpi::StringMap<std::unique_ptr<Storage>> storage;
+  std::vector<std::function<void()>> workspaceInit;
+  std::vector<std::function<void()>> workspaceReset;
+  std::string storageLoadDir = ".";
+  std::string storageAutoSaveDir = ".";
+  std::string storageName = "imgui";
+  wpi::SmallVector<Storage*, 32> storageStack;
+  wpi::StringMap<std::unique_ptr<Storage>> storageRoots;
   wpi::StringMap<bool> deviceHidden;
-  IniSaverString<DataSourceName> sources{"Data Sources"};
+  wpi::StringMap<DataSource*> sources;
+  Storage& sourceNameStorage;
   uint64_t zeroTime = 0;
+  bool isPlatformSaveDir = false;
 };
 
 extern Context* gContext;
diff --git a/glass/src/lib/native/include/glass/DataSource.h b/glass/src/lib/native/include/glass/DataSource.h
index 1d5c37b..5eebb3c 100644
--- a/glass/src/lib/native/include/glass/DataSource.h
+++ b/glass/src/lib/native/include/glass/DataSource.h
@@ -16,8 +16,6 @@
 
 namespace glass {
 
-class NameInfo;
-
 /**
  * A data source for numeric/boolean data.
  */
@@ -33,15 +31,9 @@
 
   const char* GetId() const { return m_id.c_str(); }
 
-  void SetName(std::string_view name);
-  const char* GetName() const;
-  NameInfo& GetNameInfo() { return *m_name; }
-
-  void PushEditNameId(int index);
-  void PushEditNameId(const char* name);
-  bool PopupEditName(int index);
-  bool PopupEditName(const char* name);
-  bool InputTextName(const char* label_id, ImGuiInputTextFlags flags = 0);
+  void SetName(std::string_view name) { m_name = name; }
+  std::string& GetName() { return m_name; }
+  const std::string& GetName() const { return m_name; }
 
   void SetDigital(bool digital) { m_digital = digital; }
   bool IsDigital() const { return m_digital; }
@@ -53,8 +45,9 @@
   double GetValue() const { return m_value; }
 
   // drag source helpers
-  void LabelText(const char* label, const char* fmt, ...) const;
-  void LabelTextV(const char* label, const char* fmt, va_list args) const;
+  void LabelText(const char* label, const char* fmt, ...) const IM_FMTARGS(3);
+  void LabelTextV(const char* label, const char* fmt, va_list args) const
+      IM_FMTLIST(3);
   bool Combo(const char* label, int* current_item, const char* const items[],
              int items_count, int popup_max_height_in_items = -1) const;
   bool SliderFloat(const char* label, float* v, float v_min, float v_max,
@@ -74,7 +67,7 @@
 
  private:
   std::string m_id;
-  NameInfo* m_name;
+  std::string& m_name;
   bool m_digital = false;
   std::atomic<double> m_value = 0;
 };
diff --git a/glass/src/lib/native/include/glass/MainMenuBar.h b/glass/src/lib/native/include/glass/MainMenuBar.h
index 7a6a2fc..9141536 100644
--- a/glass/src/lib/native/include/glass/MainMenuBar.h
+++ b/glass/src/lib/native/include/glass/MainMenuBar.h
@@ -4,13 +4,14 @@
 
 #pragma once
 
+#include <portable-file-dialogs.h>
+
 #include <functional>
+#include <memory>
 #include <vector>
 
 namespace glass {
 
-class WindowManager;
-
 /**
  * GUI main menu bar.
  */
@@ -22,6 +23,11 @@
   void Display();
 
   /**
+   * Displays workspace menu.  Called by Display().
+   */
+  void WorkspaceMenu();
+
+  /**
    * Adds to GUI's main menu bar.  The menu function is called from within a
    * ImGui::BeginMainMenuBar()/EndMainMenuBar() block.  Usually it's only
    * appropriate to create a menu with ImGui::BeginMenu()/EndMenu() inside of
@@ -43,6 +49,8 @@
  private:
   std::vector<std::function<void()>> m_optionMenus;
   std::vector<std::function<void()>> m_menus;
+  std::unique_ptr<pfd::select_folder> m_openFolder;
+  std::unique_ptr<pfd::select_folder> m_saveFolder;
 };
 
 }  // namespace glass
diff --git a/glass/src/lib/native/include/glass/Provider.h b/glass/src/lib/native/include/glass/Provider.h
index 53b1e75..f620d52 100644
--- a/glass/src/lib/native/include/glass/Provider.h
+++ b/glass/src/lib/native/include/glass/Provider.h
@@ -19,6 +19,8 @@
 
 namespace glass {
 
+class Storage;
+
 namespace detail {
 struct ProviderFunctions {
   using Exists = std::function<bool()>;
@@ -49,9 +51,9 @@
   /**
    * Constructor.
    *
-   * @param iniName Group name to use in ini file
+   * @param storage Storage
    */
-  explicit Provider(std::string_view iniName) : WindowManager{iniName} {}
+  explicit Provider(Storage& storage) : WindowManager{storage} {}
 
   Provider(const Provider&) = delete;
   Provider& operator=(const Provider&) = delete;
@@ -133,6 +135,7 @@
     ModelEntry* modelEntry;
     ViewExistsFunc exists;
     CreateViewFunc createView;
+    bool showDefault = false;
     Window* window = nullptr;
   };
 
diff --git a/glass/src/lib/native/include/glass/Provider.inc b/glass/src/lib/native/include/glass/Provider.inc
index 33bb6e0..7370cca 100644
--- a/glass/src/lib/native/include/glass/Provider.inc
+++ b/glass/src/lib/native/include/glass/Provider.inc
@@ -26,7 +26,7 @@
   if (it == m_viewEntries.end() || (*it)->name != name) {
     return;
   }
-  this->Show(it->get(), (*it)->window);
+  (*it)->showDefault = true;
 }
 
 template <typename Functions>
diff --git a/glass/src/lib/native/include/glass/Storage.h b/glass/src/lib/native/include/glass/Storage.h
new file mode 100644
index 0000000..004b8b4
--- /dev/null
+++ b/glass/src/lib/native/include/glass/Storage.h
@@ -0,0 +1,293 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <stdint.h>
+
+#include <functional>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include <wpi/StringMap.h>
+#include <wpi/iterator_range.h>
+#include <wpi/span.h>
+
+namespace wpi {
+class json;
+}  // namespace wpi
+
+namespace glass {
+
+namespace detail {
+template <typename IteratorType>
+class ChildIterator;
+}  // namespace detail
+
+/**
+ * Storage provides both persistent and non-persistent nested key/value storage
+ * for widgets.
+ *
+ * Keys are always strings.  The storage also provides non-persistent arbitrary
+ * data storage (via std::shared_ptr<void>).
+ *
+ * Storage is automatically indexed internally by the ID stack.  Note it is
+ * necessary to use the glass wrappers for PushID et al to preserve naming in
+ * the save file (unnamed values are still stored, but this is non-ideal for
+ * users trying to hand-edit the save file).
+ */
+class Storage {
+ public:
+  struct Value {
+    enum Type {
+      kNone,
+      kInt,
+      kInt64,
+      kBool,
+      kFloat,
+      kDouble,
+      kString,
+      kChild,
+      kIntArray,
+      kInt64Array,
+      kBoolArray,
+      kFloatArray,
+      kDoubleArray,
+      kStringArray,
+      kChildArray
+    };
+
+    Value() = default;
+    explicit Value(Type type) : type{type} {}
+    Value(const Value&) = delete;
+    Value& operator=(const Value&) = delete;
+    ~Value() { Reset(kNone); }
+
+    Type type = kNone;
+    union {
+      int intVal;
+      int64_t int64Val;
+      bool boolVal;
+      float floatVal;
+      double doubleVal;
+      Storage* child;
+      std::vector<int>* intArray;
+      std::vector<int64_t>* int64Array;
+      std::vector<int>* boolArray;
+      std::vector<float>* floatArray;
+      std::vector<double>* doubleArray;
+      std::vector<std::string>* stringArray;
+      std::vector<std::unique_ptr<Storage>>* childArray;
+    };
+    std::string stringVal;
+
+    union {
+      int intDefault;
+      int64_t int64Default;
+      bool boolDefault;
+      float floatDefault;
+      double doubleDefault;
+      // pointers may be nullptr to indicate empty
+      std::vector<int>* intArrayDefault;
+      std::vector<int64_t>* int64ArrayDefault;
+      std::vector<int>* boolArrayDefault;
+      std::vector<float>* floatArrayDefault;
+      std::vector<double>* doubleArrayDefault;
+      std::vector<std::string>* stringArrayDefault;
+    };
+    std::string stringDefault;
+
+    bool hasDefault = false;
+
+    void Reset(Type newType);
+  };
+
+  using ValueMap = wpi::StringMap<std::unique_ptr<Value>>;
+  template <typename Iterator>
+  using ChildIterator = detail::ChildIterator<Iterator>;
+
+  // The "Read" functions don't create or overwrite the value
+  int ReadInt(std::string_view key, int defaultVal = 0) const;
+  int64_t ReadInt64(std::string_view key, int64_t defaultVal = 0) const;
+  bool ReadBool(std::string_view key, bool defaultVal = false) const;
+  float ReadFloat(std::string_view key, float defaultVal = 0.0f) const;
+  double ReadDouble(std::string_view key, double defaultVal = 0.0) const;
+  std::string ReadString(std::string_view key,
+                         std::string_view defaultVal = {}) const;
+
+  void SetInt(std::string_view key, int val);
+  void SetInt64(std::string_view key, int64_t val);
+  void SetBool(std::string_view key, bool val);
+  void SetFloat(std::string_view key, float val);
+  void SetDouble(std::string_view key, double val);
+  void SetString(std::string_view key, std::string_view val);
+
+  // The "Get" functions create or override the current value type.
+  // If the value is not set, it is set to the provided default.
+  int& GetInt(std::string_view key, int defaultVal = 0);
+  int64_t& GetInt64(std::string_view key, int64_t defaultVal = 0);
+  bool& GetBool(std::string_view key, bool defaultVal = false);
+  float& GetFloat(std::string_view key, float defaultVal = 0.0f);
+  double& GetDouble(std::string_view key, double defaultVal = 0.0);
+  std::string& GetString(std::string_view key,
+                         std::string_view defaultVal = {});
+
+  std::vector<int>& GetIntArray(std::string_view key,
+                                wpi::span<const int> defaultVal = {});
+  std::vector<int64_t>& GetInt64Array(std::string_view key,
+                                      wpi::span<const int64_t> defaultVal = {});
+  std::vector<int>& GetBoolArray(std::string_view key,
+                                 wpi::span<const int> defaultVal = {});
+  std::vector<float>& GetFloatArray(std::string_view key,
+                                    wpi::span<const float> defaultVal = {});
+  std::vector<double>& GetDoubleArray(std::string_view key,
+                                      wpi::span<const double> defaultVal = {});
+  std::vector<std::string>& GetStringArray(
+      std::string_view key, wpi::span<const std::string> defaultVal = {});
+  std::vector<std::unique_ptr<Storage>>& GetChildArray(std::string_view key);
+
+  Value* FindValue(std::string_view key);
+  Value& GetValue(std::string_view key);
+  Storage& GetChild(std::string_view label_id);
+
+  void SetData(std::shared_ptr<void>&& data) { m_data = std::move(data); }
+
+  template <typename T>
+  T* GetData() const {
+    return static_cast<T*>(m_data.get());
+  }
+
+  Storage() = default;
+  Storage(const Storage&) = delete;
+  Storage& operator=(const Storage&) = delete;
+
+  void Insert(std::string_view key, std::unique_ptr<Value> value) {
+    m_values[key] = std::move(value);
+  }
+
+  std::unique_ptr<Value> Erase(std::string_view key);
+
+  void EraseAll() { m_values.clear(); }
+
+  ValueMap& GetValues() { return m_values; }
+  const ValueMap& GetValues() const { return m_values; }
+
+  wpi::iterator_range<ChildIterator<ValueMap::iterator>> GetChildren();
+
+  /**
+   * Erases all children (at top level).
+   */
+  void EraseChildren();
+
+  bool FromJson(const wpi::json& json, const char* filename);
+  wpi::json ToJson() const;
+
+  /**
+   * Clear settings (set to default).  Calls custom clear function (if set),
+   * otherwise calls ClearValues().
+   */
+  void Clear();
+
+  /**
+   * Clear values (and values of children) only (set to default).  Does not
+   * call custom clear function.
+   */
+  void ClearValues();
+
+  /**
+   * Apply settings (called after all settings have been loaded).  Calls
+   * custom apply function (if set), otherwise calls ApplyChildren().
+   */
+  void Apply();
+
+  /**
+   * Apply settings to children.  Does not call custom apply function.
+   */
+  void ApplyChildren();
+
+  /**
+   * Sets custom JSON handlers (replaces FromJson and ToJson).
+   *
+   * @param fromJson replacement for FromJson()
+   * @param toJson replacement for ToJson()
+   */
+  void SetCustomJson(
+      std::function<bool(const wpi::json& json, const char* filename)> fromJson,
+      std::function<wpi::json()> toJson) {
+    m_fromJson = std::move(fromJson);
+    m_toJson = std::move(toJson);
+  }
+
+  void SetCustomClear(std::function<void()> clear) {
+    m_clear = std::move(clear);
+  }
+
+  void SetCustomApply(std::function<void()> apply) {
+    m_apply = std::move(apply);
+  }
+
+ private:
+  mutable ValueMap m_values;
+  std::shared_ptr<void> m_data;
+  std::function<bool(const wpi::json& json, const char* filename)> m_fromJson;
+  std::function<wpi::json()> m_toJson;
+  std::function<void()> m_clear;
+  std::function<void()> m_apply;
+};
+
+namespace detail {
+
+/// proxy class for the GetChildren() function
+template <typename IteratorType>
+class ChildIterator {
+ private:
+  /// the iterator
+  IteratorType anchor;
+  IteratorType end;
+
+ public:
+  ChildIterator(IteratorType it, IteratorType end) noexcept
+      : anchor(it), end(end) {
+    while (anchor != end &&
+           anchor->getValue()->type != Storage::Value::kChild) {
+      ++anchor;
+    }
+  }
+
+  /// dereference operator (needed for range-based for)
+  ChildIterator& operator*() { return *this; }
+
+  /// increment operator (needed for range-based for)
+  ChildIterator& operator++() {
+    ++anchor;
+    while (anchor != end &&
+           anchor->getValue()->type != Storage::Value::kChild) {
+      ++anchor;
+    }
+    return *this;
+  }
+
+  /// inequality operator (needed for range-based for)
+  bool operator!=(const ChildIterator& o) const noexcept {
+    return anchor != o.anchor;
+  }
+
+  /// return key of the iterator
+  std::string_view key() const { return anchor->getKey(); }
+
+  /// return value of the iterator
+  Storage& value() const { return *anchor->getValue()->child; }
+};
+
+}  // namespace detail
+
+inline auto Storage::GetChildren()
+    -> wpi::iterator_range<ChildIterator<ValueMap::iterator>> {
+  return {{m_values.begin(), m_values.end()}, {m_values.end(), m_values.end()}};
+}
+
+}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/Window.h b/glass/src/lib/native/include/glass/Window.h
index 780479a..0a37f9a 100644
--- a/glass/src/lib/native/include/glass/Window.h
+++ b/glass/src/lib/native/include/glass/Window.h
@@ -15,19 +15,21 @@
 
 namespace glass {
 
+class Storage;
+
 /**
  * Managed window information.
  * A Window owns the View that displays the window's contents.
  */
 class Window {
  public:
-  Window() = default;
-  explicit Window(std::string_view id) : m_id{id}, m_defaultName{id} {}
+  enum Visibility { kHide = 0, kShow, kDisabled };
+
+  Window(Storage& storage, std::string_view id,
+         Visibility defaultVisibility = kShow);
 
   std::string_view GetId() const { return m_id; }
 
-  enum Visibility { kHide = 0, kShow, kDisabled };
-
   bool HasView() { return static_cast<bool>(m_view); }
 
   void SetView(std::unique_ptr<View> view) { m_view = std::move(view); }
@@ -60,6 +62,13 @@
   void SetVisibility(Visibility visibility);
 
   /**
+   * Sets default visibility of window.
+   *
+   * @param visibility 0=hide, 1=show, 2=disabled (force-hide)
+   */
+  void SetDefaultVisibility(Visibility visibility);
+
+  /**
    * Sets default position of window.
    *
    * @param x x location of upper left corner
@@ -109,17 +118,16 @@
    */
   void ScaleDefault(float scale);
 
-  void IniReadLine(const char* lineStr);
-  void IniWriteAll(const char* typeName, ImGuiTextBuffer* out_buf);
-
  private:
   std::string m_id;
-  std::string m_name;
+  std::string& m_name;
   std::string m_defaultName;
   std::unique_ptr<View> m_view;
   ImGuiWindowFlags m_flags = 0;
-  bool m_visible = true;
-  bool m_enabled = true;
+  bool& m_visible;
+  bool& m_enabled;
+  bool& m_defaultVisible;
+  bool& m_defaultEnabled;
   bool m_renamePopupEnabled = true;
   ImGuiCond m_posCond = 0;
   ImGuiCond m_sizeCond = 0;
diff --git a/glass/src/lib/native/include/glass/WindowManager.h b/glass/src/lib/native/include/glass/WindowManager.h
index 4024e15..0ccace2 100644
--- a/glass/src/lib/native/include/glass/WindowManager.h
+++ b/glass/src/lib/native/include/glass/WindowManager.h
@@ -4,20 +4,18 @@
 
 #pragma once
 
-#include <functional>
 #include <memory>
 #include <string_view>
-#include <type_traits>
 #include <vector>
 
-#include <imgui.h>
 #include <wpi/FunctionExtras.h>
 
 #include "glass/Window.h"
-#include "glass/support/IniSaverBase.h"
 
 namespace glass {
 
+class Storage;
+
 /**
  * Window manager.
  *
@@ -31,9 +29,9 @@
   /**
    * Constructor.
    *
-   * @param iniName Group name to use in ini file
+   * @param storage Storage for window information
    */
-  explicit WindowManager(std::string_view iniName);
+  explicit WindowManager(Storage& storage);
   virtual ~WindowManager() = default;
 
   WindowManager(const WindowManager&) = delete;
@@ -65,8 +63,10 @@
    *
    * @param id unique identifier of the window (title bar)
    * @param display window contents display function
+   * @param defaultVisibility default window visibility
    */
-  Window* AddWindow(std::string_view id, wpi::unique_function<void()> display);
+  Window* AddWindow(std::string_view id, wpi::unique_function<void()> display,
+                    Window::Visibility defaultVisibility = Window::kShow);
 
   /**
    * Adds window to GUI.  The view's display function is called from within a
@@ -82,9 +82,11 @@
    *
    * @param id unique identifier of the window (title bar)
    * @param view view object
+   * @param defaultVisibility default window visibility
    * @return Window, or nullptr on duplicate window
    */
-  Window* AddWindow(std::string_view id, std::unique_ptr<View> view);
+  Window* AddWindow(std::string_view id, std::unique_ptr<View> view,
+                    Window::Visibility defaultVisibility = Window::kShow);
 
   /**
    * Adds window to GUI.  A View must be assigned to the returned Window
@@ -99,9 +101,12 @@
    * every frame in the gui::AddExecute() function.
    *
    * @param id unique identifier of the window (default title bar)
+   * @param duplicateOk if false, warn on duplicates
+   * @param defaultVisibility default window visibility
    * @return Window, or nullptr on duplicate window
    */
-  Window* GetOrAddWindow(std::string_view id, bool duplicateOk = false);
+  Window* GetOrAddWindow(std::string_view id, bool duplicateOk = false,
+                         Window::Visibility defaultVisibility = Window::kShow);
 
   /**
    * Gets existing window.  If none exists, returns nullptr.
@@ -111,27 +116,26 @@
    */
   Window* GetWindow(std::string_view id);
 
+  /**
+   * Erases all windows.
+   */
+  void EraseWindows() { m_windows.clear(); }
+
  protected:
-  virtual void DisplayWindows();
+  /**
+   * Removes existing window (by index)
+   *
+   * @param index index of window in m_windows
+   */
+  void RemoveWindow(size_t index);
 
   // kept sorted by id
   std::vector<std::unique_ptr<Window>> m_windows;
 
+  Storage& m_storage;
+
  private:
-  class IniSaver : public IniSaverBase {
-   public:
-    explicit IniSaver(std::string_view typeName, WindowManager* manager)
-        : IniSaverBase{typeName}, m_manager{manager} {}
-
-    void* IniReadOpen(const char* name) override;
-    void IniReadLine(void* entry, const char* lineStr) override;
-    void IniWriteAll(ImGuiTextBuffer* out_buf) override;
-
-   private:
-    WindowManager* m_manager;
-  };
-
-  IniSaver m_iniSaver;
+  void DisplayWindows();
 };
 
 }  // namespace glass
diff --git a/glass/src/lib/native/include/glass/other/FMS.h b/glass/src/lib/native/include/glass/other/FMS.h
index 1e0f8ef..a920f96 100644
--- a/glass/src/lib/native/include/glass/other/FMS.h
+++ b/glass/src/lib/native/include/glass/other/FMS.h
@@ -47,7 +47,7 @@
  * @param matchTimeEnabled If not null, a checkbox is displayed for
  *                         "enable match time" linked to this value
  */
-void DisplayFMS(FMSModel* model, bool* matchTimeEnabled = nullptr);
+void DisplayFMS(FMSModel* model);
 void DisplayFMSReadOnly(FMSModel* model);
 
 }  // namespace glass
diff --git a/glass/src/lib/native/include/glass/other/Plot.h b/glass/src/lib/native/include/glass/other/Plot.h
index f7b196d..3e27b9d 100644
--- a/glass/src/lib/native/include/glass/other/Plot.h
+++ b/glass/src/lib/native/include/glass/other/Plot.h
@@ -4,19 +4,16 @@
 
 #pragma once
 
-#include <string_view>
-
 #include "glass/WindowManager.h"
-#include "glass/support/IniSaverBase.h"
 
 namespace glass {
 
 class PlotProvider : private WindowManager {
  public:
-  explicit PlotProvider(std::string_view iniName);
+  explicit PlotProvider(Storage& storage);
   ~PlotProvider() override;
 
-  void GlobalInit() override;
+  using WindowManager::GlobalInit;
 
   /**
    * Pauses or unpauses all plots.
@@ -33,24 +30,6 @@
   void DisplayMenu() override;
 
  private:
-  void DisplayWindows() override;
-
-  class IniSaver : public IniSaverBase {
-   public:
-    explicit IniSaver(std::string_view typeName, PlotProvider* provider,
-                      bool forSeries);
-
-    void* IniReadOpen(const char* name) override;
-    void IniReadLine(void* entry, const char* lineStr) override;
-    void IniWriteAll(ImGuiTextBuffer* out_buf) override;
-
-   private:
-    PlotProvider* m_provider;
-    bool m_forSeries;
-  };
-
-  IniSaver m_plotSaver;
-  IniSaver m_seriesSaver;
   bool m_paused = false;
 };
 
diff --git a/glass/src/lib/native/include/glass/support/ColorSetting.h b/glass/src/lib/native/include/glass/support/ColorSetting.h
new file mode 100644
index 0000000..a6604dd
--- /dev/null
+++ b/glass/src/lib/native/include/glass/support/ColorSetting.h
@@ -0,0 +1,53 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <vector>
+
+#include <imgui.h>
+
+namespace glass {
+
+class ColorSetting {
+ public:
+  explicit ColorSetting(std::vector<float>& color);
+
+  ImVec4 GetColor() const {
+    return {m_color[0], m_color[1], m_color[2], m_color[3]};
+  }
+
+  float* GetColorFloat() { return m_color.data(); }
+  const float* GetColorFloat() const { return m_color.data(); }
+
+  void SetColor(const ImVec4& color) {
+    m_color[0] = color.x;
+    m_color[1] = color.y;
+    m_color[2] = color.z;
+    m_color[3] = color.w;
+  }
+
+  // updates internal value, returns true on change
+  bool ColorEdit3(const char* label, ImGuiColorEditFlags flags = 0) {
+    return ImGui::ColorEdit3(label, m_color.data(), flags);
+  }
+
+  bool ColorEdit4(const char* label, ImGuiColorEditFlags flags = 0) {
+    return ImGui::ColorEdit4(label, m_color.data(), flags);
+  }
+
+  bool ColorPicker3(const char* label, ImGuiColorEditFlags flags = 0) {
+    return ImGui::ColorPicker3(label, m_color.data(), flags);
+  }
+
+  bool ColorPicker4(const char* label, ImGuiColorEditFlags flags = 0,
+                    const float* ref_col = nullptr) {
+    return ImGui::ColorPicker4(label, m_color.data(), flags, ref_col);
+  }
+
+ private:
+  std::vector<float>& m_color;
+};
+
+}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/support/EnumSetting.h b/glass/src/lib/native/include/glass/support/EnumSetting.h
new file mode 100644
index 0000000..c4f0c26
--- /dev/null
+++ b/glass/src/lib/native/include/glass/support/EnumSetting.h
@@ -0,0 +1,31 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <string>
+
+#include <wpi/SmallVector.h>
+
+namespace glass {
+
+class EnumSetting {
+ public:
+  EnumSetting(std::string& str, int defaultValue,
+              std::initializer_list<const char*> choices);
+
+  int GetValue() const { return m_value; }
+  void SetValue(int value);
+
+  // updates internal value, returns true on change
+  bool Combo(const char* label, int numOptions = -1,
+             int popup_max_height_in_items = -1);
+
+ private:
+  std::string& m_str;
+  wpi::SmallVector<const char*, 8> m_choices;
+  int m_value;
+};
+
+}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/support/IniSaver.h b/glass/src/lib/native/include/glass/support/IniSaver.h
deleted file mode 100644
index a3aa3d0..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaver.h
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string_view>
-
-#include <imgui.h>
-#include <wpi/DenseMap.h>
-
-#include "glass/support/IniSaverBase.h"
-
-namespace glass {
-
-template <typename Info>
-class IniSaver : public IniSaverBase {
- public:
-  explicit IniSaver(std::string_view typeName,
-                    IniSaverBackend* backend = nullptr)
-      : IniSaverBase(typeName, backend) {}
-
-  // pass through useful functions to map
-  Info& operator[](int index) { return m_map[index]; }
-
-  auto begin() { return m_map.begin(); }
-  auto end() { return m_map.end(); }
-  auto find(int index) { return m_map.find(index); }
-
-  auto begin() const { return m_map.begin(); }
-  auto end() const { return m_map.end(); }
-  auto find(int index) const { return m_map.find(index); }
-
- private:
-  void* IniReadOpen(const char* name) override;
-  void IniReadLine(void* entry, const char* lineStr) override;
-  void IniWriteAll(ImGuiTextBuffer* out_buf) override;
-
-  wpi::DenseMap<int, Info> m_map;
-};
-
-}  // namespace glass
-
-#include "IniSaver.inc"
diff --git a/glass/src/lib/native/include/glass/support/IniSaver.inc b/glass/src/lib/native/include/glass/support/IniSaver.inc
deleted file mode 100644
index 42efb85..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaver.inc
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string_view>
-
-#include <wpi/StringExtras.h>
-
-#include "glass/support/IniSaver.h"
-
-namespace glass {
-
-template <typename Info>
-void* IniSaver<Info>::IniReadOpen(const char* name) {
-  if (auto num = wpi::parse_integer<int>(name, 10)) {
-    return &m_map[num.value()];
-  } else {
-    return nullptr;
-  }
-}
-
-template <typename Info>
-void IniSaver<Info>::IniReadLine(void* entry, const char* line) {
-  auto element = static_cast<Info*>(entry);
-  auto [name, value] = wpi::split(line, '=');
-  element->ReadIni(wpi::trim(name), wpi::trim(value));
-}
-
-template <typename Info>
-void IniSaver<Info>::IniWriteAll(ImGuiTextBuffer* out_buf) {
-  for (auto&& it : m_map) {
-    out_buf->appendf("[%s][%d]\n", GetTypeName(), it.first);
-    it.second.WriteIni(out_buf);
-    out_buf->append("\n");
-  }
-}
-
-}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/support/IniSaverBase.h b/glass/src/lib/native/include/glass/support/IniSaverBase.h
deleted file mode 100644
index 85ae1e3..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaverBase.h
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string>
-#include <string_view>
-
-#include <imgui.h>
-
-namespace glass {
-
-class IniSaverBase;
-
-class IniSaverBackend {
- public:
-  virtual ~IniSaverBackend() = default;
-  virtual void Register(IniSaverBase* iniSaver) = 0;
-  virtual void Unregister(IniSaverBase* iniSaver) = 0;
-};
-
-class IniSaverBase {
- public:
-  explicit IniSaverBase(std::string_view typeName,
-                        IniSaverBackend* backend = nullptr);
-  virtual ~IniSaverBase();
-
-  void Initialize() { m_backend->Register(this); }
-
-  const char* GetTypeName() const { return m_typeName.c_str(); }
-  IniSaverBackend* GetBackend() const { return m_backend; }
-
-  IniSaverBase(const IniSaverBase&) = delete;
-  IniSaverBase& operator=(const IniSaverBase&) = delete;
-
-  virtual void* IniReadOpen(const char* name) = 0;
-  virtual void IniReadLine(void* entry, const char* lineStr) = 0;
-  virtual void IniWriteAll(ImGuiTextBuffer* out_buf) = 0;
-
- private:
-  std::string m_typeName;
-  IniSaverBackend* m_backend;
-};
-
-}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/support/IniSaverInfo.h b/glass/src/lib/native/include/glass/support/IniSaverInfo.h
deleted file mode 100644
index 2014e9d..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaverInfo.h
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string_view>
-
-#include <imgui.h>
-
-namespace glass {
-
-class NameInfo {
- public:
-  NameInfo() { m_name[0] = '\0'; }
-
-  bool HasName() const { return m_name[0] != '\0'; }
-  void SetName(std::string_view name);
-  const char* GetName() const { return m_name; }
-  void GetName(char* buf, size_t size, const char* defaultName) const;
-  void GetName(char* buf, size_t size, const char* defaultName,
-               int index) const;
-  void GetName(char* buf, size_t size, const char* defaultName, int index,
-               int index2) const;
-  void GetLabel(char* buf, size_t size, const char* defaultName) const;
-  void GetLabel(char* buf, size_t size, const char* defaultName,
-                int index) const;
-  void GetLabel(char* buf, size_t size, const char* defaultName, int index,
-                int index2) const;
-
-  bool ReadIni(std::string_view name, std::string_view value);
-  void WriteIni(ImGuiTextBuffer* out);
-  void PushEditNameId(int index);
-  void PushEditNameId(const char* name);
-  bool PopupEditName(int index);
-  bool PopupEditName(const char* name);
-  bool InputTextName(const char* label_id, ImGuiInputTextFlags flags = 0);
-
- private:
-  char m_name[64];
-};
-
-class OpenInfo {
- public:
-  OpenInfo() = default;
-  explicit OpenInfo(bool open) : m_open(open) {}
-
-  bool IsOpen() const { return m_open; }
-  void SetOpen(bool open) { m_open = open; }
-  bool ReadIni(std::string_view name, std::string_view value);
-  void WriteIni(ImGuiTextBuffer* out);
-
- private:
-  bool m_open = false;
-};
-
-class NameOpenInfo : public NameInfo, public OpenInfo {
- public:
-  bool ReadIni(std::string_view name, std::string_view value);
-  void WriteIni(ImGuiTextBuffer* out);
-};
-
-}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/support/IniSaverString.h b/glass/src/lib/native/include/glass/support/IniSaverString.h
deleted file mode 100644
index 4134219..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaverString.h
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string_view>
-#include <utility>
-
-#include <imgui.h>
-#include <wpi/StringMap.h>
-
-#include "glass/support/IniSaverBase.h"
-
-namespace glass {
-
-template <typename Info>
-class IniSaverString : public IniSaverBase {
- public:
-  explicit IniSaverString(std::string_view typeName,
-                          IniSaverBackend* backend = nullptr)
-      : IniSaverBase(typeName, backend) {}
-
-  // pass through useful functions to map
-  Info& operator[](std::string_view key) { return m_map[key]; }
-
-  template <typename... ArgsTy>
-  auto try_emplace(std::string_view key, ArgsTy&&... args) {
-    return m_map.try_emplace(key, std::forward<ArgsTy>(args)...);
-  }
-
-  void erase(typename wpi::StringMap<Info>::iterator it) { m_map.erase(it); }
-  auto erase(std::string_view key) { return m_map.erase(key); }
-
-  auto begin() { return m_map.begin(); }
-  auto end() { return m_map.end(); }
-  auto find(std::string_view key) { return m_map.find(key); }
-
-  auto begin() const { return m_map.begin(); }
-  auto end() const { return m_map.end(); }
-  auto find(std::string_view key) const { return m_map.find(key); }
-
-  bool empty() const { return m_map.empty(); }
-
- private:
-  void* IniReadOpen(const char* name) override;
-  void IniReadLine(void* entry, const char* lineStr) override;
-  void IniWriteAll(ImGuiTextBuffer* out_buf) override;
-
-  wpi::StringMap<Info> m_map;
-};
-
-}  // namespace glass
-
-#include "IniSaverString.inc"
diff --git a/glass/src/lib/native/include/glass/support/IniSaverString.inc b/glass/src/lib/native/include/glass/support/IniSaverString.inc
deleted file mode 100644
index 0d18d29..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaverString.inc
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string_view>
-
-#include <wpi/StringExtras.h>
-
-#include "glass/support/IniSaverString.h"
-
-namespace glass {
-
-template <typename Info>
-void* IniSaverString<Info>::IniReadOpen(const char* name) {
-  return &m_map[name];
-}
-
-template <typename Info>
-void IniSaverString<Info>::IniReadLine(void* entry, const char* line) {
-  auto element = static_cast<Info*>(entry);
-  auto [name, value] = wpi::split(line, '=');
-  element->ReadIni(wpi::trim(name), wpi::trim(value));
-}
-
-template <typename Info>
-void IniSaverString<Info>::IniWriteAll(ImGuiTextBuffer* out_buf) {
-  for (auto&& it : m_map) {
-    out_buf->appendf("[%s][%s]\n", GetTypeName(), it.getKey().data());
-    it.second.WriteIni(out_buf);
-    out_buf->append("\n");
-  }
-}
-
-}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/support/IniSaverVector.h b/glass/src/lib/native/include/glass/support/IniSaverVector.h
deleted file mode 100644
index e2e57ce..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaverVector.h
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string_view>
-#include <vector>
-
-#include <imgui.h>
-
-#include "glass/support/IniSaverBase.h"
-
-namespace glass {
-
-template <typename Info>
-class IniSaverVector : public std::vector<Info>, public IniSaverBase {
- public:
-  explicit IniSaverVector(std::string_view typeName,
-                          IniSaverBackend* backend = nullptr)
-      : IniSaverBase(typeName, backend) {}
-
- private:
-  void* IniReadOpen(const char* name) override;
-  void IniReadLine(void* entry, const char* lineStr) override;
-  void IniWriteAll(ImGuiTextBuffer* out_buf) override;
-};
-
-}  // namespace glass
-
-#include "IniSaverVector.inc"
diff --git a/glass/src/lib/native/include/glass/support/IniSaverVector.inc b/glass/src/lib/native/include/glass/support/IniSaverVector.inc
deleted file mode 100644
index a86b116..0000000
--- a/glass/src/lib/native/include/glass/support/IniSaverVector.inc
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (c) FIRST and other WPILib contributors.
-// Open Source Software; you can modify and/or share it under the terms of
-// the WPILib BSD license file in the root directory of this project.
-
-#pragma once
-
-#include <string_view>
-
-#include <wpi/StringExtras.h>
-
-#include "glass/support/IniSaverVector.h"
-
-namespace glass {
-
-template <typename Info>
-void* IniSaverVector<Info>::IniReadOpen(const char* name) {
-  if (auto num = wpi::parse_integer<unsigned int>(name, 10)) {
-    if (num.value() >= this->size()) {
-      this->resize(num.value() + 1);
-    }
-    return &(*this)[num.value()];
-  } else {
-    return nullptr;
-  }
-}
-
-template <typename Info>
-void IniSaverVector<Info>::IniReadLine(void* entry, const char* line) {
-  auto element = static_cast<Info*>(entry);
-  auto [name, value] = wpi::split(line, '=');
-  element->ReadIni(wpi::trim(name), wpi::trim(value));
-}
-
-template <typename Info>
-void IniSaverVector<Info>::IniWriteAll(ImGuiTextBuffer* out_buf) {
-  for (size_t i = 0; i < this->size(); ++i) {
-    out_buf->appendf("[%s][%d]\n", GetTypeName(), static_cast<int>(i));
-    (*this)[i].WriteIni(out_buf);
-    out_buf->append("\n");
-  }
-}
-
-}  // namespace glass
diff --git a/glass/src/lib/native/include/glass/support/NameSetting.h b/glass/src/lib/native/include/glass/support/NameSetting.h
new file mode 100644
index 0000000..e1444e3
--- /dev/null
+++ b/glass/src/lib/native/include/glass/support/NameSetting.h
@@ -0,0 +1,43 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <string>
+#include <string_view>
+
+#include <imgui.h>
+
+namespace glass {
+
+class NameSetting {
+ public:
+  explicit NameSetting(std::string& str) : m_name{str} {}
+
+  bool HasName() const { return !m_name.empty(); }
+  void SetName(std::string_view name) { m_name = name; }
+  std::string& GetName() { return m_name; }
+  const std::string& GetName() const { return m_name; }
+  void GetName(char* buf, size_t size, const char* defaultName) const;
+  void GetName(char* buf, size_t size, const char* defaultName,
+               int index) const;
+  void GetName(char* buf, size_t size, const char* defaultName, int index,
+               int index2) const;
+  void GetLabel(char* buf, size_t size, const char* defaultName) const;
+  void GetLabel(char* buf, size_t size, const char* defaultName,
+                int index) const;
+  void GetLabel(char* buf, size_t size, const char* defaultName, int index,
+                int index2) const;
+
+  void PushEditNameId(int index);
+  void PushEditNameId(const char* name);
+  bool PopupEditName(int index);
+  bool PopupEditName(const char* name);
+  bool InputTextName(const char* label_id, ImGuiInputTextFlags flags = 0);
+
+ private:
+  std::string& m_name;
+};
+
+}  // namespace glass
diff --git a/glass/src/libnt/native/cpp/NetworkTables.cpp b/glass/src/libnt/native/cpp/NetworkTables.cpp
index 596ef0a..bce2b0a 100644
--- a/glass/src/libnt/native/cpp/NetworkTables.cpp
+++ b/glass/src/libnt/native/cpp/NetworkTables.cpp
@@ -25,6 +25,7 @@
 
 #include "glass/Context.h"
 #include "glass/DataSource.h"
+#include "glass/Storage.h"
 
 using namespace glass;
 
@@ -731,15 +732,15 @@
 void NetworkTablesFlagsSettings::Update() {
   if (!m_pTreeView) {
     auto& storage = GetStorage();
-    m_pTreeView = storage.GetBoolRef(
-        "tree", m_defaultFlags & NetworkTablesFlags_TreeView);
-    m_pShowConnections = storage.GetBoolRef(
+    m_pTreeView =
+        &storage.GetBool("tree", m_defaultFlags & NetworkTablesFlags_TreeView);
+    m_pShowConnections = &storage.GetBool(
         "connections", m_defaultFlags & NetworkTablesFlags_ShowConnections);
-    m_pShowFlags = storage.GetBoolRef(
+    m_pShowFlags = &storage.GetBool(
         "flags", m_defaultFlags & NetworkTablesFlags_ShowFlags);
-    m_pShowTimestamp = storage.GetBoolRef(
+    m_pShowTimestamp = &storage.GetBool(
         "timestamp", m_defaultFlags & NetworkTablesFlags_ShowTimestamp);
-    m_pCreateNoncanonicalKeys = storage.GetBoolRef(
+    m_pCreateNoncanonicalKeys = &storage.GetBool(
         "createNonCanonical",
         m_defaultFlags & NetworkTablesFlags_CreateNoncanonicalKeys);
   }
diff --git a/glass/src/libnt/native/cpp/NetworkTablesProvider.cpp b/glass/src/libnt/native/cpp/NetworkTablesProvider.cpp
index 8d991cb..9ccbb2e 100644
--- a/glass/src/libnt/native/cpp/NetworkTablesProvider.cpp
+++ b/glass/src/libnt/native/cpp/NetworkTablesProvider.cpp
@@ -12,23 +12,53 @@
 #include <wpi/StringExtras.h>
 #include <wpigui.h>
 
+#include "glass/Storage.h"
+
 using namespace glass;
 
-NetworkTablesProvider::NetworkTablesProvider(std::string_view iniName)
-    : NetworkTablesProvider{iniName, nt::GetDefaultInstance()} {}
+NetworkTablesProvider::NetworkTablesProvider(Storage& storage)
+    : NetworkTablesProvider{storage, nt::GetDefaultInstance()} {}
 
-NetworkTablesProvider::NetworkTablesProvider(std::string_view iniName,
-                                             NT_Inst inst)
-    : Provider{fmt::format("{}Window", iniName)},
+NetworkTablesProvider::NetworkTablesProvider(Storage& storage, NT_Inst inst)
+    : Provider{storage.GetChild("windows")},
       m_nt{inst},
-      m_typeCache{iniName} {
-  m_nt.AddListener("", NT_NOTIFY_LOCAL | NT_NOTIFY_NEW | NT_NOTIFY_DELETE |
-                           NT_NOTIFY_IMMEDIATE);
-}
+      m_typeCache{storage.GetChild("types")} {
+  storage.SetCustomApply([this] {
+    m_listener =
+        m_nt.AddListener("", NT_NOTIFY_LOCAL | NT_NOTIFY_NEW |
+                                 NT_NOTIFY_DELETE | NT_NOTIFY_IMMEDIATE);
+    for (auto&& childIt : m_storage.GetChildren()) {
+      auto id = childIt.key();
+      auto typePtr = m_typeCache.FindValue(id);
+      if (!typePtr || typePtr->type != Storage::Value::kString) {
+        continue;
+      }
 
-void NetworkTablesProvider::GlobalInit() {
-  Provider::GlobalInit();
-  wpi::gui::AddInit([this] { m_typeCache.Initialize(); });
+      // only handle ones where we have a builder
+      auto builderIt = m_typeMap.find(typePtr->stringVal);
+      if (builderIt == m_typeMap.end()) {
+        continue;
+      }
+
+      auto entry = GetOrCreateView(
+          builderIt->second,
+          nt::GetEntry(m_nt.GetInstance(), fmt::format("{}/.type", id)), id);
+      if (entry) {
+        Show(entry, nullptr);
+      }
+    }
+  });
+  storage.SetCustomClear([this, &storage] {
+    nt::RemoveEntryListener(m_listener);
+    m_listener = 0;
+    for (auto&& modelEntry : m_modelEntries) {
+      modelEntry->model.reset();
+    }
+    m_viewEntries.clear();
+    m_windows.clear();
+    m_typeCache.EraseAll();
+    storage.ClearValues();
+  });
 }
 
 void NetworkTablesProvider::DisplayMenu() {
@@ -98,33 +128,7 @@
     } else if (event.flags & NT_NOTIFY_NEW) {
       GetOrCreateView(builderIt->second, event.entry, tableName);
       // cache the type
-      m_typeCache[tableName].SetName(event.value->GetString());
-    }
-  }
-
-  // check for visible windows that need displays (typically this is due to
-  // file loading)
-  for (auto&& window : m_windows) {
-    if (!window->IsVisible() || window->HasView()) {
-      continue;
-    }
-    auto id = window->GetId();
-    auto typeIt = m_typeCache.find(id);
-    if (typeIt == m_typeCache.end()) {
-      continue;
-    }
-
-    // only handle ones where we have a builder
-    auto builderIt = m_typeMap.find(typeIt->second.GetName());
-    if (builderIt == m_typeMap.end()) {
-      continue;
-    }
-
-    auto entry = GetOrCreateView(
-        builderIt->second,
-        nt::GetEntry(m_nt.GetInstance(), fmt::format("{}/.type", id)), id);
-    if (entry) {
-      Show(entry, window.get());
+      m_typeCache.SetString(tableName, event.value->GetString());
     }
   }
 }
@@ -153,7 +157,7 @@
 
   // the window might exist and we're just not associated to it yet
   if (!window) {
-    window = GetOrAddWindow(entry->name, true);
+    window = GetOrAddWindow(entry->name, true, Window::kHide);
   }
   if (!window) {
     return;
diff --git a/glass/src/libnt/native/cpp/NetworkTablesSettings.cpp b/glass/src/libnt/native/cpp/NetworkTablesSettings.cpp
index 28f4de4..d1fb341 100644
--- a/glass/src/libnt/native/cpp/NetworkTablesSettings.cpp
+++ b/glass/src/libnt/native/cpp/NetworkTablesSettings.cpp
@@ -15,6 +15,7 @@
 #include <wpi/StringExtras.h>
 
 #include "glass/Context.h"
+#include "glass/Storage.h"
 
 using namespace glass;
 
@@ -81,15 +82,12 @@
   }
 }
 
-NetworkTablesSettings::NetworkTablesSettings(NT_Inst inst,
-                                             const char* storageName) {
-  auto& storage = glass::GetStorage(storageName);
-  m_pMode = storage.GetIntRef("mode");
-  m_pIniName = storage.GetStringRef("iniName", "networktables.ini");
-  m_pServerTeam = storage.GetStringRef("serverTeam");
-  m_pListenAddress = storage.GetStringRef("listenAddress");
-  m_pDsClient = storage.GetBoolRef("dsClient", true);
-
+NetworkTablesSettings::NetworkTablesSettings(Storage& storage, NT_Inst inst)
+    : m_mode{storage.GetString("mode"), 0, {"Disabled", "Client", "Server"}},
+      m_iniName{storage.GetString("iniName", "networktables.ini")},
+      m_serverTeam{storage.GetString("serverTeam")},
+      m_listenAddress{storage.GetString("listenAddress")},
+      m_dsClient{storage.GetBool("dsClient", true)} {
   m_thread.Start(inst);
 }
 
@@ -102,25 +100,24 @@
   // do actual operation on thread
   auto thr = m_thread.GetThread();
   thr->m_restart = true;
-  thr->m_mode = *m_pMode;
-  thr->m_iniName = *m_pIniName;
-  thr->m_serverTeam = *m_pServerTeam;
-  thr->m_listenAddress = *m_pListenAddress;
-  thr->m_dsClient = *m_pDsClient;
+  thr->m_mode = m_mode.GetValue();
+  thr->m_iniName = m_iniName;
+  thr->m_serverTeam = m_serverTeam;
+  thr->m_listenAddress = m_listenAddress;
+  thr->m_dsClient = m_dsClient;
   thr->m_cond.notify_one();
 }
 
 bool NetworkTablesSettings::Display() {
-  static const char* modeOptions[] = {"Disabled", "Client", "Server"};
-  ImGui::Combo("Mode", m_pMode, modeOptions, m_serverOption ? 3 : 2);
-  switch (*m_pMode) {
+  m_mode.Combo("Mode", m_serverOption ? 3 : 2);
+  switch (m_mode.GetValue()) {
     case 1:
-      ImGui::InputText("Team/IP", m_pServerTeam);
-      ImGui::Checkbox("Get Address from DS", m_pDsClient);
+      ImGui::InputText("Team/IP", &m_serverTeam);
+      ImGui::Checkbox("Get Address from DS", &m_dsClient);
       break;
     case 2:
-      ImGui::InputText("Listen Address", m_pListenAddress);
-      ImGui::InputText("ini Filename", m_pIniName);
+      ImGui::InputText("Listen Address", &m_listenAddress);
+      ImGui::InputText("ini Filename", &m_iniName);
       break;
     default:
       break;
diff --git a/glass/src/libnt/native/include/glass/networktables/NTSpeedController.h b/glass/src/libnt/native/include/glass/networktables/NTSpeedController.h
index 756d947..a79c266 100644
--- a/glass/src/libnt/native/include/glass/networktables/NTSpeedController.h
+++ b/glass/src/libnt/native/include/glass/networktables/NTSpeedController.h
@@ -16,7 +16,7 @@
 namespace glass {
 class NTSpeedControllerModel : public SpeedControllerModel {
  public:
-  static constexpr const char* kType = "Speed Controller";
+  static constexpr const char* kType = "Motor Controller";
 
   explicit NTSpeedControllerModel(std::string_view path);
   NTSpeedControllerModel(NT_Inst instance, std::string_view path);
diff --git a/glass/src/libnt/native/include/glass/networktables/NetworkTablesProvider.h b/glass/src/libnt/native/include/glass/networktables/NetworkTablesProvider.h
index 17374ca..a8f0f9b 100644
--- a/glass/src/libnt/native/include/glass/networktables/NetworkTablesProvider.h
+++ b/glass/src/libnt/native/include/glass/networktables/NetworkTablesProvider.h
@@ -9,14 +9,13 @@
 #include <string_view>
 #include <vector>
 
+#include <ntcore_c.h>
 #include <ntcore_cpp.h>
 #include <wpi/StringMap.h>
 
 #include "glass/Model.h"
 #include "glass/Provider.h"
 #include "glass/networktables/NetworkTablesHelper.h"
-#include "glass/support/IniSaverInfo.h"
-#include "glass/support/IniSaverString.h"
 
 namespace glass {
 
@@ -41,8 +40,8 @@
   using Provider::CreateModelFunc;
   using Provider::CreateViewFunc;
 
-  explicit NetworkTablesProvider(std::string_view iniName);
-  NetworkTablesProvider(std::string_view iniName, NT_Inst inst);
+  explicit NetworkTablesProvider(Storage& storage);
+  NetworkTablesProvider(Storage& storage, NT_Inst inst);
 
   /**
    * Get the NetworkTables instance being used for this provider.
@@ -55,7 +54,7 @@
    * Perform global initialization.  This should be called prior to
    * wpi::gui::Initialize().
    */
-  void GlobalInit() override;
+  void GlobalInit() override { Provider::GlobalInit(); }
 
   /**
    * Displays menu contents as a tree of available NetworkTables views.
@@ -72,15 +71,14 @@
   void Register(std::string_view typeName, CreateModelFunc createModel,
                 CreateViewFunc createView);
 
-  using WindowManager::AddWindow;
-
  private:
   void Update() override;
 
   NetworkTablesHelper m_nt;
+  NT_EntryListener m_listener{0};
 
   // cached mapping from table name to type string
-  IniSaverString<NameInfo> m_typeCache;
+  Storage& m_typeCache;
 
   struct Builder {
     CreateModelFunc createModel;
diff --git a/glass/src/libnt/native/include/glass/networktables/NetworkTablesSettings.h b/glass/src/libnt/native/include/glass/networktables/NetworkTablesSettings.h
index 7738541..a1a1c93 100644
--- a/glass/src/libnt/native/include/glass/networktables/NetworkTablesSettings.h
+++ b/glass/src/libnt/native/include/glass/networktables/NetworkTablesSettings.h
@@ -9,6 +9,8 @@
 #include <ntcore_cpp.h>
 #include <wpi/SafeThread.h>
 
+#include "glass/support/EnumSetting.h"
+
 namespace wpi {
 template <typename T>
 class SmallVectorImpl;
@@ -16,11 +18,12 @@
 
 namespace glass {
 
+class Storage;
+
 class NetworkTablesSettings {
  public:
-  explicit NetworkTablesSettings(
-      NT_Inst inst = nt::GetDefaultInstance(),
-      const char* storageName = "NetworkTables Settings");
+  explicit NetworkTablesSettings(Storage& storage,
+                                 NT_Inst inst = nt::GetDefaultInstance());
 
   /**
    * Enables or disables the server option.  Default is enabled.
@@ -33,11 +36,11 @@
  private:
   bool m_restart = true;
   bool m_serverOption = true;
-  int* m_pMode;
-  std::string* m_pIniName;
-  std::string* m_pServerTeam;
-  std::string* m_pListenAddress;
-  bool* m_pDsClient;
+  EnumSetting m_mode;
+  std::string& m_iniName;
+  std::string& m_serverTeam;
+  std::string& m_listenAddress;
+  bool& m_dsClient;
 
   class Thread : public wpi::SafeThread {
    public: