diff --git a/PROGRESS.md b/PROGRESS.md index d5f5cd4..87c86cd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -184,6 +184,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [x] Step 84: **IMPLEMENTED** — Native file dialogs via tinyfiledialogs, FileDialog wrapper with test provider injection (2/2 tests pass) - [x] Step 85: **IMPLEMENTED** — Filesystem tree with .gitignore filtering, recursive Explorer rendering (1/1 tests pass) - [x] Step 86: **IMPLEMENTED** — Multi-tab editing with BufferManager and per-buffer editor state (3/3 tests pass) +- [x] Step 87: **IMPLEMENTED** — Welcome screen wired with recent files persistence and quick actions (2/2 tests pass) --- @@ -249,6 +250,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 84:** Compile and pass (2/2) **Step 85:** Compile and pass (1/1) **Step 86:** Compile and pass (3/3) +**Step 87:** Compile and pass (2/2) --- @@ -343,3 +345,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e | 2026-02-09 | Codex | Step 84: Native file dialogs via tinyfiledialogs; FileDialog wrapper with injectable provider. 2/2 tests pass. | | 2026-02-09 | Codex | Step 85: Filesystem tree with .gitignore filtering and Explorer rendering. 1/1 tests pass. | | 2026-02-09 | Codex | Step 86: Multi-tab editing wired via BufferManager with per-buffer state. 3/3 tests pass. | +| 2026-02-09 | Codex | Step 87: Welcome screen wired with recent files persistence and quick actions. 2/2 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 6b36f4f..f4a0ad2 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -493,6 +493,9 @@ target_include_directories(step85_test PRIVATE src) add_executable(step86_test tests/step86_test.cpp) target_include_directories(step86_test PRIVATE src) +add_executable(step87_test tests/step87_test.cpp) +target_include_directories(step87_test PRIVATE src) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 253dec5..d736eb2 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -21,6 +21,7 @@ #include "EditorMode.h" #include "FileDialog.h" #include "FileTree.h" +#include "WelcomeScreen.h" #include "ast/Generator.h" #include @@ -33,6 +34,8 @@ #include #include #include +#include +#include // --------------------------------------------------------------------------- // Editor application state @@ -71,6 +74,7 @@ struct EditorState { // Custom editor widget state bool showWhitespace = false; bool showMinimap = false; + WelcomeScreen welcome; std::string workspaceRoot; FileTree fileTree; FileNode fileTreeRoot; @@ -115,27 +119,49 @@ struct EditorState { activeBuffer = bufferStates[path].get(); } - void init() { - std::string defaultContent = - "def add(x, y):\n" - " \"\"\"Add two numbers.\"\"\"\n" - " result = x + y\n" - " return result\n" - "\n" - "def multiply(a, b):\n" - " return a * b\n" - "\n" - "# Calculate the sum of a list\n" - "def calculate_sum(items):\n" - " total = 0\n" - " for item in items:\n" - " total = total + item\n" - " return total\n"; + std::filesystem::path configDir() const { + const char* home = std::getenv("USERPROFILE"); + if (!home) home = std::getenv("HOME"); + std::filesystem::path base = home ? std::filesystem::path(home) : std::filesystem::current_path(); + return base / ".whetstone"; + } - createBuffer(makeUntitledName(), defaultContent, "python"); + std::filesystem::path recentFilePath() const { + return configDir() / "recent.json"; + } + + void loadRecentFiles() { + std::filesystem::path p = recentFilePath(); + if (!std::filesystem::exists(p)) return; + std::ifstream in(p.string()); + if (!in.is_open()) return; + nlohmann::json j; + in >> j; + if (!j.is_array()) return; + for (const auto& item : j) { + if (!item.contains("path")) continue; + std::string path = item.value("path", ""); + std::string lang = item.value("language", ""); + if (!path.empty()) welcome.addRecentFile(path, lang); + } + } + + void saveRecentFiles() { + std::filesystem::path p = recentFilePath(); + std::filesystem::create_directories(p.parent_path()); + nlohmann::json j = nlohmann::json::array(); + for (const auto& rf : welcome.getRecentFiles()) { + j.push_back({{"path", rf.path}, {"language", rf.language}}); + } + std::ofstream out(p.string(), std::ios::binary); + out << j.dump(2); + } + + void init() { outputLog = "Whetstone Editor ready.\n"; workspaceRoot = std::filesystem::current_path().string(); fileTreeDirty = true; + loadRecentFiles(); } void setLanguage(const std::string& lang) { @@ -250,6 +276,8 @@ struct EditorState { else if (path.size() > 3 && path.substr(path.size() - 3) == ".go") language = "go"; createBuffer(path, content, language); + welcome.addRecentFile(path, language); + saveRecentFiles(); outputLog += "Opened: " + path + "\n"; } else { outputLog += "Error opening: " + path + "\n"; @@ -482,6 +510,50 @@ static void RenderFileTree(const FileNode& node, EditorState& state) { } } +// --------------------------------------------------------------------------- +// Welcome screen rendering +// --------------------------------------------------------------------------- +static void RenderWelcome(WelcomeScreen& welcome, EditorState& state, std::string& lastDialogPath) { + ImGui::Text("%s", welcome.getTitle().c_str()); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", welcome.getSubtitle().c_str()); + ImGui::Separator(); + + ImGui::Text("Quick Actions"); + if (ImGui::Button("New File")) { + std::string lang = state.active() ? state.active()->language : "python"; + state.createBuffer(state.makeUntitledName(), "", lang); + } + ImGui::SameLine(); + if (ImGui::Button("Open File")) { + auto path = FileDialog::openFile({"Open File", {"*.py","*.cpp","*.h","*.el","*.js","*.ts","*.java","*.rs","*.go"}, lastDialogPath}); + if (!path.empty()) { + lastDialogPath = path; + state.doOpen(path); + } + } + ImGui::SameLine(); + if (ImGui::Button("Open Folder")) { + auto path = FileDialog::openFolder({"Open Folder", state.workspaceRoot}); + if (!path.empty()) { + state.workspaceRoot = path; + state.fileTreeDirty = true; + lastDialogPath = path; + } + } + + ImGui::Separator(); + ImGui::Text("Recent Files"); + for (const auto& rf : welcome.getRecentFiles()) { + if (ImGui::Selectable(rf.displayName.c_str())) { + state.doOpen(rf.path); + } + } + + ImGui::Separator(); + ImGui::Text("Tip"); + ImGui::TextWrapped("%s", welcome.getTip(0).c_str()); +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -763,44 +835,51 @@ int main(int, char**) { // Tab bar for the file if (ImGui::BeginTabBar("EditorTabs")) { - auto openBuffers = state.buffers.getOpenBuffers(); - for (const auto& path : openBuffers) { - auto* buf = state.bufferStates[path].get(); - if (!buf) continue; - std::string tabLabel = path; - if (buf->modified) tabLabel += " *"; - bool open = true; - if (ImGui::BeginTabItem(tabLabel.c_str(), &open)) { - if (!state.active() || state.active()->path != path) { - state.switchToBuffer(path); - } - // Editable text area fills available space - ImVec2 avail = ImGui::GetContentRegionAvail(); - avail.y -= 4; // small margin - - state.updateHighlights(); - CodeEditorOptions opts; - opts.showWhitespace = state.showWhitespace; - opts.mode = &buf->mode; - opts.enableFolding = true; - opts.showMinimap = state.showMinimap; - - CodeEditorResult res = buf->widget.render("##editor", - buf->editBuf, buf->highlights, opts, avail, monoFont); - state.updateCursorPos(res.cursorByte); - if (res.changed) { - state.onTextChanged(); - } - + if (state.buffers.bufferCount() == 0) { + if (ImGui::BeginTabItem("Welcome")) { + RenderWelcome(state.welcome, state, lastDialogPath); ImGui::EndTabItem(); } - if (!open) { - state.buffers.closeBuffer(path); - state.bufferStates.erase(path); - if (state.buffers.bufferCount() > 0) { - state.switchToBuffer(state.buffers.getOpenBuffers().front()); - } else { - state.activeBuffer = nullptr; + } else { + auto openBuffers = state.buffers.getOpenBuffers(); + for (const auto& path : openBuffers) { + auto* buf = state.bufferStates[path].get(); + if (!buf) continue; + std::string tabLabel = path; + if (buf->modified) tabLabel += " *"; + bool open = true; + if (ImGui::BeginTabItem(tabLabel.c_str(), &open)) { + if (!state.active() || state.active()->path != path) { + state.switchToBuffer(path); + } + // Editable text area fills available space + ImVec2 avail = ImGui::GetContentRegionAvail(); + avail.y -= 4; // small margin + + state.updateHighlights(); + CodeEditorOptions opts; + opts.showWhitespace = state.showWhitespace; + opts.mode = &buf->mode; + opts.enableFolding = true; + opts.showMinimap = state.showMinimap; + + CodeEditorResult res = buf->widget.render("##editor", + buf->editBuf, buf->highlights, opts, avail, monoFont); + state.updateCursorPos(res.cursorByte); + if (res.changed) { + state.onTextChanged(); + } + + ImGui::EndTabItem(); + } + if (!open) { + state.buffers.closeBuffer(path); + state.bufferStates.erase(path); + if (state.buffers.bufferCount() > 0) { + state.switchToBuffer(state.buffers.getOpenBuffers().front()); + } else { + state.activeBuffer = nullptr; + } } } } diff --git a/editor/tests/step87_test.cpp b/editor/tests/step87_test.cpp new file mode 100644 index 0000000..fa92a9b --- /dev/null +++ b/editor/tests/step87_test.cpp @@ -0,0 +1,29 @@ +// Step 87 TDD Test: Welcome screen +// +// Tests: +// 1. Default actions and tips are present +// 2. Recent files added and de-duplicated + +#include +#include +#include "WelcomeScreen.h" + +int main() { + int passed = 0; + int failed = 0; + + WelcomeScreen w; + assert(!w.getActions().empty()); + assert(!w.getTips().empty()); + std::cout << "Test 1 PASS: Actions and tips" << std::endl; + ++passed; + + w.addRecentFile("C:/tmp/a.py", "python"); + w.addRecentFile("C:/tmp/a.py", "python"); + assert(w.getRecentFiles().size() == 1); + std::cout << "Test 2 PASS: Recent files de-duplicate" << std::endl; + ++passed; + + std::cout << "\n=== Step 87 Results: " << passed << " passed, " << failed << " failed ===" << std::endl; + return failed > 0 ? 1 : 0; +}