diff --git a/PROGRESS.md b/PROGRESS.md index 7708f84..498f7e8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -522,3 +522,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 172: Bundled theme pack renamed to Whetstone Dark/Light and Monokai Pro with tests. 2/2 tests pass (step172_test, step172_integration_test). file_limits_test 4/4 passes. | | 2026-02-10 | Codex | Step 173: Theme gallery UI with hover preview, apply/reset, and swatches. 2/2 tests pass (step173_test, step173_integration_test). file_limits_test 4/4 passes. | | 2026-02-10 | Codex | Step 174: Added IconSet system with theme-aware, zoom-scaled icons + tests. 2/2 tests pass (step174_test, step174_integration_test). file_limits_test 4/4 passes. | +| 2026-02-10 | Codex | Step 175: Typography controls (fonts, line height, letter spacing) + theme layout spacing. 2/2 tests pass (step175_test, step175_integration_test). file_limits_test 4/4 passes. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index edf278e..a04e555 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1009,6 +1009,13 @@ add_executable(step174_integration_test tests/step174_integration_test.cpp) target_include_directories(step174_integration_test PRIVATE src) target_link_libraries(step174_integration_test PRIVATE imgui::imgui) +add_executable(step175_test tests/step175_test.cpp) +target_include_directories(step175_test PRIVATE src) + +add_executable(step175_integration_test tests/step175_integration_test.cpp) +target_include_directories(step175_integration_test PRIVATE src) +target_link_libraries(step175_integration_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/CodeEditorRendering.h b/editor/src/CodeEditorRendering.h index ad1f701..536b727 100644 --- a/editor/src/CodeEditorRendering.h +++ b/editor/src/CodeEditorRendering.h @@ -40,8 +40,10 @@ public: } // Measure - const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); - const float charAdvance = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, "M").x; + const float lineHeight = + ImGui::GetTextLineHeightWithSpacing() * std::max(0.5f, options.lineHeightScale); + const float baseAdvance = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, "M").x; + const float charAdvance = baseAdvance + std::max(0.0f, options.letterSpacing); const float gutterWidth = options.showLineNumbers ? calcGutterWidth(lineCount, font, charAdvance) : 12.0f; const float minimapWidth = options.showMinimap ? 80.0f : 0.0f; diff --git a/editor/src/CodeEditorWidget.h b/editor/src/CodeEditorWidget.h index f1c42c6..2ab7416 100644 --- a/editor/src/CodeEditorWidget.h +++ b/editor/src/CodeEditorWidget.h @@ -25,6 +25,8 @@ struct CodeEditorOptions { bool showAnnotations = false; bool showLineNumbers = true; bool showCurrentLine = true; + float lineHeightScale = 1.0f; + float letterSpacing = 0.0f; int annotationLayout = 0; // 0=above, 1=margin, 2=beside const std::vector* errorLines = nullptr; const std::vector* warningLines = nullptr; diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index dcdb006..85d24a3 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -159,6 +159,7 @@ struct EditorState { ImFont* monoFont = nullptr; ImFont* uiFont = nullptr; float baseFontSize = 15.0f; + bool fontsDirty = false; std::string lastDialogPath; bool exitRequested = false; diff --git a/editor/src/OrgMode.h b/editor/src/OrgMode.h index 1c46115..0b8db95 100644 --- a/editor/src/OrgMode.h +++ b/editor/src/OrgMode.h @@ -175,6 +175,8 @@ static bool renderOrgDocument(OrgDocumentState& state, bool showWhitespace, ImFont* monoFont, ImFont* uiFont, + float lineHeightScale, + float letterSpacing, const std::function& onOrgTextChanged, const std::function& onBlockChanged, const std::function& onRunBlock) { @@ -200,6 +202,8 @@ static bool renderOrgDocument(OrgDocumentState& state, opts.readOnly = false; opts.showWhitespace = showWhitespace; opts.showLineNumbers = true; + opts.lineHeightScale = lineHeightScale; + opts.letterSpacing = letterSpacing; EditorMode& mode = state.modes[srcIndex]; mode.setLanguage(block.language); opts.mode = &mode; diff --git a/editor/src/SettingsManager.h b/editor/src/SettingsManager.h index 41b71e1..e178665 100644 --- a/editor/src/SettingsManager.h +++ b/editor/src/SettingsManager.h @@ -30,6 +30,14 @@ public: std::string& getEmacsConfigPathMutable() { return emacsConfigPath_; } int getFontSize() const { return fontSize_; } void setFontSize(int size) { fontSize_ = size; } + const std::string& getCodeFontPath() const { return codeFontPath_; } + void setCodeFontPath(const std::string& path) { codeFontPath_ = path; } + const std::string& getUiFontPath() const { return uiFontPath_; } + void setUiFontPath(const std::string& path) { uiFontPath_ = path; } + float getLineHeightScale() const { return lineHeightScale_; } + void setLineHeightScale(float value) { lineHeightScale_ = value; } + float getLetterSpacing() const { return letterSpacing_; } + void setLetterSpacing(float value) { letterSpacing_ = value; } int getTabSize() const { return tabSize_; } void setTabSize(int size) { tabSize_ = size; } const std::string& getTheme() const { return theme_; } @@ -58,6 +66,10 @@ public: nlohmann::json j; in >> j; fontSize_ = j.value("fontSize", fontSize_); + codeFontPath_ = j.value("codeFont", codeFontPath_); + uiFontPath_ = j.value("uiFont", uiFontPath_); + lineHeightScale_ = j.value("lineHeight", lineHeightScale_); + letterSpacing_ = j.value("letterSpacing", letterSpacing_); tabSize_ = j.value("tabSize", tabSize_); theme_ = j.value("theme", theme_); telemetryOptIn_ = j.value("telemetryOptIn", telemetryOptIn_); @@ -90,6 +102,10 @@ public: try { nlohmann::json j; j["fontSize"] = fontSize_; + j["codeFont"] = codeFontPath_; + j["uiFont"] = uiFontPath_; + j["lineHeight"] = lineHeightScale_; + j["letterSpacing"] = letterSpacing_; j["tabSize"] = tabSize_; j["theme"] = theme_; j["telemetryOptIn"] = telemetryOptIn_; @@ -159,6 +175,10 @@ private: std::vector lspServers_; std::string emacsConfigPath_; int fontSize_ = 15; + std::string codeFontPath_; + std::string uiFontPath_; + float lineHeightScale_ = 1.0f; + float letterSpacing_ = 0.0f; int tabSize_ = 4; std::string theme_ = "Whetstone Dark"; bool telemetryOptIn_ = false; diff --git a/editor/src/ThemeEngine.h b/editor/src/ThemeEngine.h index 4069df7..7a46c61 100644 --- a/editor/src/ThemeEngine.h +++ b/editor/src/ThemeEngine.h @@ -45,6 +45,8 @@ struct ThemeDefinition { std::unordered_map editorColors; std::unordered_map imguiColors; std::string sourcePath; + ImVec2 panelPadding = ImVec2(8.0f, 6.0f); + ImVec2 panelSpacing = ImVec2(8.0f, 6.0f); }; class ThemeEngine { @@ -312,6 +314,19 @@ private: } } } + if (j.contains("layout") && j["layout"].is_object()) { + const auto& layout = j["layout"]; + if (layout.contains("panelPadding") && layout["panelPadding"].is_array() && + layout["panelPadding"].size() >= 2) { + out.panelPadding.x = layout["panelPadding"][0].get(); + out.panelPadding.y = layout["panelPadding"][1].get(); + } + if (layout.contains("panelSpacing") && layout["panelSpacing"].is_array() && + layout["panelSpacing"].size() >= 2) { + out.panelSpacing.x = layout["panelSpacing"][0].get(); + out.panelSpacing.y = layout["panelSpacing"][1].get(); + } + } return true; } @@ -322,6 +337,8 @@ private: style.Colors[colId] = color; } } + style.WindowPadding = theme.panelPadding; + style.ItemSpacing = theme.panelSpacing; } static bool isSyntaxColor(ThemeColor color) { diff --git a/editor/src/main.cpp b/editor/src/main.cpp index e32c455..aacc6e3 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "EditorState.h" #include "EditorUtils.h" @@ -120,6 +122,60 @@ int main(int, char**) { state.uiFont = uiFont; state.baseFontSize = baseFontSize; state.init(); + + auto loadFontFromPath = [&](const std::string& preferred, + const std::vector& fallbacks, + float size) -> ImFont* { + namespace fs = std::filesystem; + if (!preferred.empty() && fs::exists(preferred)) { + if (auto* f = io.Fonts->AddFontFromFileTTF(preferred.c_str(), size)) return f; + } + for (const auto& path : fallbacks) { + if (fs::exists(path)) { + if (auto* f = io.Fonts->AddFontFromFileTTF(path.c_str(), size)) return f; + } + } + return io.Fonts->AddFontDefault(); + }; + + auto reloadFonts = [&]() { + ImGui_ImplOpenGL3_DestroyFontsTexture(); + io.Fonts->Clear(); + std::vector codeFallbacks; + std::vector uiFallbacks; +#ifdef _WIN32 + codeFallbacks = { + "C:\\Windows\\Fonts\\consola.ttf", + "C:\\Windows\\Fonts\\CascadiaCode.ttf", + "C:\\Windows\\Fonts\\CascadiaMono.ttf", + "C:\\Windows\\Fonts\\cour.ttf" + }; + uiFallbacks = { + "C:\\Windows\\Fonts\\segoeui.ttf", + "C:\\Windows\\Fonts\\calibri.ttf", + "C:\\Windows\\Fonts\\arial.ttf" + }; +#else + codeFallbacks = { + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf" + }; + uiFallbacks = { + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf" + }; +#endif + state.monoFont = loadFontFromPath(state.settings.getCodeFontPath(), + codeFallbacks, + state.baseFontSize); + state.uiFont = loadFontFromPath(state.settings.getUiFontPath(), + uiFallbacks, + state.baseFontSize); + io.FontGlobalScale = state.settings.getFontSize() / state.baseFontSize; + ImGui_ImplOpenGL3_CreateFontsTexture(); + }; + + reloadFonts(); ThemeEngine& themes = ThemeEngine::instance(); themes.loadThemesFromDirectory("themes"); themes.loadThemesFromDirectory("editor/themes"); @@ -256,6 +312,10 @@ int main(int, char**) { state.refreshEmacsModeLine(ImGui::GetTime()); state.events.tick(ImGui::GetTime()); themes.refreshWatchedThemes(); + if (state.fontsDirty) { + reloadFonts(); + state.fontsDirty = false; + } // --- Start frame --- ImGui_ImplOpenGL3_NewFrame(); diff --git a/editor/src/panels/BottomPanel.h b/editor/src/panels/BottomPanel.h index afd30ac..3ef501f 100644 --- a/editor/src/panels/BottomPanel.h +++ b/editor/src/panels/BottomPanel.h @@ -538,6 +538,8 @@ static void renderBottomPanel(EditorState& state) { leftOpts.showWhitespace = state.ui.showWhitespace; leftOpts.showLineNumbers = state.ui.showLineNumbers; leftOpts.showCurrentLine = false; + leftOpts.lineHeightScale = state.settings.getLineHeightScale(); + leftOpts.letterSpacing = state.settings.getLetterSpacing(); leftOpts.highlightLines = &state.diff.beforeLines; leftOpts.highlightLineColor = IM_COL32(160, 80, 80, 120); leftOpts.syncScrollX = &state.diffScrollX; @@ -553,6 +555,8 @@ static void renderBottomPanel(EditorState& state) { rightOpts.showWhitespace = state.ui.showWhitespace; rightOpts.showLineNumbers = state.ui.showLineNumbers; rightOpts.showCurrentLine = false; + rightOpts.lineHeightScale = state.settings.getLineHeightScale(); + rightOpts.letterSpacing = state.settings.getLetterSpacing(); rightOpts.highlightLines = &state.diff.afterLines; rightOpts.highlightLineColor = IM_COL32(80, 160, 80, 120); rightOpts.syncScrollX = &state.diffScrollX; diff --git a/editor/src/panels/EditorPanel.h b/editor/src/panels/EditorPanel.h index 9d527f2..f663b5b 100644 --- a/editor/src/panels/EditorPanel.h +++ b/editor/src/panels/EditorPanel.h @@ -153,6 +153,8 @@ static void renderEditorPanel(EditorState& state) { state.ui.showWhitespace, state.monoFont, state.uiFont, + state.settings.getLineHeightScale(), + state.settings.getLetterSpacing(), [&](const std::string& newText) { buf->editBuf = newText; state.onTextChanged(); @@ -173,6 +175,8 @@ static void renderEditorPanel(EditorState& state) { opts.showMinimap = state.ui.showMinimap; opts.showAnnotations = state.ui.showAnnotations; opts.showLineNumbers = state.ui.showLineNumbers; + opts.lineHeightScale = state.settings.getLineHeightScale(); + opts.letterSpacing = state.settings.getLetterSpacing(); if (state.ui.layoutPreset == LayoutPreset::Emacs) opts.annotationLayout = 1; else if (state.ui.layoutPreset == LayoutPreset::JetBrains) opts.annotationLayout = 2; else opts.annotationLayout = 0; @@ -226,6 +230,8 @@ static void renderEditorPanel(EditorState& state) { genOpts.mode = &buf->generatedMode; genOpts.showLineNumbers = state.ui.showLineNumbers; genOpts.showCurrentLine = false; + genOpts.lineHeightScale = state.settings.getLineHeightScale(); + genOpts.letterSpacing = state.settings.getLetterSpacing(); genOpts.highlightLine = buf->generatedHighlightLine; genOpts.syncScrollX = &buf->splitScrollX; genOpts.syncScrollY = &buf->splitScrollY; diff --git a/editor/src/panels/SettingsPanel.h b/editor/src/panels/SettingsPanel.h index aa7830d..7ebc363 100644 --- a/editor/src/panels/SettingsPanel.h +++ b/editor/src/panels/SettingsPanel.h @@ -3,6 +3,8 @@ #include "../EditorUtils.h" #include "../ThemeEngine.h" #include +#include +#include static void openThemesFolder(const std::string& path) { #ifdef _WIN32 @@ -17,6 +19,53 @@ static void openThemesFolder(const std::string& path) { #endif } +struct FontOption { + std::string label; + std::string path; +}; + +static void pushFontOption(std::vector& out, + const std::string& label, + const std::string& path) { + std::error_code ec; + if (path.empty() || std::filesystem::exists(path, ec)) { + out.push_back({label, path}); + } +} + +static std::vector collectFontOptions(bool monospace) { + std::vector out; + pushFontOption(out, "Default", ""); +#ifdef _WIN32 + if (monospace) { + pushFontOption(out, "Consolas", "C:\\Windows\\Fonts\\consola.ttf"); + pushFontOption(out, "Cascadia Code", "C:\\Windows\\Fonts\\CascadiaCode.ttf"); + pushFontOption(out, "Cascadia Mono", "C:\\Windows\\Fonts\\CascadiaMono.ttf"); + pushFontOption(out, "Courier New", "C:\\Windows\\Fonts\\cour.ttf"); + } else { + pushFontOption(out, "Segoe UI", "C:\\Windows\\Fonts\\segoeui.ttf"); + pushFontOption(out, "Calibri", "C:\\Windows\\Fonts\\calibri.ttf"); + pushFontOption(out, "Arial", "C:\\Windows\\Fonts\\arial.ttf"); + } +#else + if (monospace) { + pushFontOption(out, "DejaVu Sans Mono", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"); + pushFontOption(out, "Liberation Mono", "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf"); + } else { + pushFontOption(out, "DejaVu Sans", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"); + pushFontOption(out, "Liberation Sans", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"); + } +#endif + return out; +} + +static int findFontIndex(const std::vector& options, const std::string& path) { + for (size_t i = 0; i < options.size(); ++i) { + if (options[i].path == path) return (int)i; + } + return 0; +} + static void renderSettingsPanel(EditorState& state) { if (!state.ui.showSettingsPanel) return; ImGui::Begin("Settings", &state.ui.showSettingsPanel); @@ -24,6 +73,7 @@ static void renderSettingsPanel(EditorState& state) { bool settingsChanged = false; bool emacsConfigChanged = false; bool themeChanged = false; + bool fontsChanged = false; ImGuiIO& io = ImGui::GetIO(); int fontSize = state.settings.getFontSize(); @@ -33,6 +83,56 @@ static void renderSettingsPanel(EditorState& state) { settingsChanged = true; } + ImGui::Separator(); + ImGui::TextUnformatted("Typography"); + + auto codeFonts = collectFontOptions(true); + int codeFontIndex = findFontIndex(codeFonts, state.settings.getCodeFontPath()); + std::vector codeFontLabels; + codeFontLabels.reserve(codeFonts.size()); + for (const auto& opt : codeFonts) codeFontLabels.push_back(opt.label.c_str()); + if (ImGui::Combo("Code Font", &codeFontIndex, codeFontLabels.data(), + (int)codeFontLabels.size())) { + state.settings.setCodeFontPath(codeFonts[codeFontIndex].path); + settingsChanged = true; + fontsChanged = true; + } + std::string codeFontPath = state.settings.getCodeFontPath(); + if (InputTextStr("Code Font Path", &codeFontPath)) { + state.settings.setCodeFontPath(codeFontPath); + settingsChanged = true; + fontsChanged = true; + } + + auto uiFonts = collectFontOptions(false); + int uiFontIndex = findFontIndex(uiFonts, state.settings.getUiFontPath()); + std::vector uiFontLabels; + uiFontLabels.reserve(uiFonts.size()); + for (const auto& opt : uiFonts) uiFontLabels.push_back(opt.label.c_str()); + if (ImGui::Combo("UI Font", &uiFontIndex, uiFontLabels.data(), + (int)uiFontLabels.size())) { + state.settings.setUiFontPath(uiFonts[uiFontIndex].path); + settingsChanged = true; + fontsChanged = true; + } + std::string uiFontPath = state.settings.getUiFontPath(); + if (InputTextStr("UI Font Path", &uiFontPath)) { + state.settings.setUiFontPath(uiFontPath); + settingsChanged = true; + fontsChanged = true; + } + + float lineHeight = state.settings.getLineHeightScale(); + if (ImGui::SliderFloat("Line Height", &lineHeight, 1.0f, 2.0f, "%.2f")) { + state.settings.setLineHeightScale(lineHeight); + settingsChanged = true; + } + float letterSpacing = state.settings.getLetterSpacing(); + if (ImGui::SliderFloat("Letter Spacing", &letterSpacing, 0.0f, 3.0f, "%.1f")) { + state.settings.setLetterSpacing(letterSpacing); + settingsChanged = true; + } + int tabSize = state.settings.getTabSize(); const int tabSizes[] = {2, 4, 8}; int tabIndex = 1; @@ -273,6 +373,9 @@ static void renderSettingsPanel(EditorState& state) { } if (settingsChanged) { + if (fontsChanged) { + state.fontsDirty = true; + } state.saveSettingsToDisk(); state.events.publish(UIEventType::SettingsChanged, {}, {}, ImGui::GetTime()); } diff --git a/editor/tests/step175_integration_test.cpp b/editor/tests/step175_integration_test.cpp new file mode 100644 index 0000000..85429fc --- /dev/null +++ b/editor/tests/step175_integration_test.cpp @@ -0,0 +1,20 @@ +// Step 175: Typography settings integration checks. + +#include + +#include "SettingsManager.h" + +int main() { + SettingsManager settings; + settings.setCodeFontPath("code.ttf"); + settings.setUiFontPath("ui.ttf"); + settings.setLineHeightScale(1.5f); + settings.setLetterSpacing(1.0f); + assert(settings.getCodeFontPath() == "code.ttf"); + assert(settings.getUiFontPath() == "ui.ttf"); + assert(settings.getLineHeightScale() == 1.5f); + assert(settings.getLetterSpacing() == 1.0f); + + printf("step175_integration_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step175_test.cpp b/editor/tests/step175_test.cpp new file mode 100644 index 0000000..2be4cb8 --- /dev/null +++ b/editor/tests/step175_test.cpp @@ -0,0 +1,44 @@ +// Step 175: Typography and spacing checks. + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +static void assertContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) != std::string::npos); +} + +int main() { + const std::string settings = readFile("src/SettingsManager.h"); + assertContains(settings, "codeFont"); + assertContains(settings, "uiFont"); + assertContains(settings, "lineHeight"); + assertContains(settings, "letterSpacing"); + + const std::string editorOpts = readFile("src/CodeEditorWidget.h"); + assertContains(editorOpts, "lineHeightScale"); + assertContains(editorOpts, "letterSpacing"); + + const std::string theme = readFile("src/ThemeEngine.h"); + assertContains(theme, "panelPadding"); + assertContains(theme, "panelSpacing"); + + const std::string settingsPanel = readFile("src/panels/SettingsPanel.h"); + assertContains(settingsPanel, "Code Font"); + assertContains(settingsPanel, "UI Font"); + assertContains(settingsPanel, "Line Height"); + assertContains(settingsPanel, "Letter Spacing"); + + printf("step175_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index 8aeeac5..d70c4d9 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -132,7 +132,7 @@ look professional and feel personal. - Theme-aware: icon colors adapt to current theme *New:* `IconSet.h` -- [ ] **Step 175: Typography and spacing** +- [x] **Step 175: Typography and spacing** Configurable typography beyond just font size: - Code font family selection (from system monospace fonts or bundled) - UI font separate from code font