Step 87: welcome screen

This commit is contained in:
Bill
2026-02-09 09:37:12 -07:00
parent 9f8ee09b4c
commit a1efe93ba3
4 changed files with 167 additions and 53 deletions

View File

@@ -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. |

View File

@@ -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)

View File

@@ -21,6 +21,7 @@
#include "EditorMode.h"
#include "FileDialog.h"
#include "FileTree.h"
#include "WelcomeScreen.h"
#include "ast/Generator.h"
#include <cstdio>
@@ -33,6 +34,8 @@
#include <filesystem>
#include <map>
#include <memory>
#include <cstdlib>
#include <nlohmann/json.hpp>
// ---------------------------------------------------------------------------
// 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;
}
}
}
}

View File

@@ -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 <cassert>
#include <iostream>
#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;
}