diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index 3f43539..ac31ce2 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include @@ -155,6 +156,21 @@ struct GitAsyncResult { std::future g_git_async_future; +enum class ToastKind { info, success, warning, error }; + +struct ToastNotification { + int id = 0; + ToastKind kind = ToastKind::info; + std::string title; + std::string message; + float remaining = 5.0f; + bool hovered = false; +}; + +std::vector g_toasts; +int g_next_toast_id = 1; +std::string g_last_toast_notice; + float ui(float value) { return value * g_ui_scale; } RepositoryView& repo() { return *g_tabs.at(g_active_tab); } @@ -221,6 +237,73 @@ bool copy_to_clipboard(std::string_view text, const char* description) { return true; } +bool contains_case_insensitive_text(std::string_view text, std::string_view needle) { + return std::search(text.begin(), text.end(), needle.begin(), needle.end(), + [](unsigned char left, unsigned char right) { + return std::tolower(left) == std::tolower(right); + }) != text.end(); +} + +ToastKind infer_toast_kind(std::string_view message) { + if (contains_case_insensitive_text(message, "forbidden") || + contains_case_insensitive_text(message, "unable") || + contains_case_insensitive_text(message, "error") || + contains_case_insensitive_text(message, "failed") || + contains_case_insensitive_text(message, "denied") || + contains_case_insensitive_text(message, "cannot") || + contains_case_insensitive_text(message, "no remote") || + contains_case_insensitive_text(message, "unsupported")) + return ToastKind::error; + if (contains_case_insensitive_text(message, "warning") || + contains_case_insensitive_text(message, "already running") || + contains_case_insensitive_text(message, "not available")) + return ToastKind::warning; + if (contains_case_insensitive_text(message, "complete") || + contains_case_insensitive_text(message, "created") || + contains_case_insensitive_text(message, "updated") || + contains_case_insensitive_text(message, "copied") || + contains_case_insensitive_text(message, "opened") || + contains_case_insensitive_text(message, "staged") || + contains_case_insensitive_text(message, "unstaged") || + contains_case_insensitive_text(message, "merged") || + contains_case_insensitive_text(message, "applied") || + contains_case_insensitive_text(message, "discarded")) + return ToastKind::success; + return ToastKind::info; +} + +std::string toast_title(ToastKind kind) { + switch (kind) { + case ToastKind::success: return "Success"; + case ToastKind::warning: return "Warning"; + case ToastKind::error: return "Error"; + default: return "Notice"; + } +} + +void push_toast(std::string message, ToastKind kind = ToastKind::info) { + if (message.empty()) return; + ToastNotification toast; + toast.id = g_next_toast_id++; + toast.kind = kind; + toast.title = toast_title(kind); + toast.message = std::move(message); + toast.remaining = kind == ToastKind::error ? 9.0f : (kind == ToastKind::warning ? 7.0f : 5.0f); + g_toasts.push_back(std::move(toast)); + if (g_toasts.size() > 6) g_toasts.erase(g_toasts.begin(), g_toasts.end() - 6); +} + +void sync_notice_toast() { + if (g_notice.empty()) { + g_last_toast_notice.clear(); + return; + } + if (g_notice == g_last_toast_notice) return; + g_last_toast_notice = g_notice; + if (g_notice.ends_with("...")) return; + push_toast(g_notice, infer_toast_kind(g_notice)); +} + void transfer_repository_state(RepositoryView& source, RepositoryView& target) { if (&source == &target) return; target.close(); @@ -441,6 +524,85 @@ void mark_action_refresh(RepositoryView& repository) { repository.pending_background_refresh = true; } +void draw_toasts() { + sync_notice_toast(); + if (g_toasts.empty()) return; + + const float margin = ui(14.0f); + const float width = ui(340.0f); + float y = ImGui::GetMainViewport()->WorkPos.y + margin; + const float right = ImGui::GetMainViewport()->WorkPos.x + ImGui::GetMainViewport()->WorkSize.x - margin; + const float delta = ImGui::GetIO().DeltaTime; + + for (size_t index = 0; index < g_toasts.size();) { + ToastNotification& toast = g_toasts[index]; + toast.hovered = false; + + ImGui::SetNextWindowPos({right - width, y}, ImGuiCond_Always); + ImGui::SetNextWindowSize({width, 0.0f}, ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, ui(7.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(10.0f), ui(9.0f)}); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {ui(8.0f), ui(6.0f)}); + const ImU32 accent_u32 = toast.kind == ToastKind::success ? IM_COL32(74, 201, 126, 255) : + toast.kind == ToastKind::warning ? IM_COL32(234, 179, 8, 255) : + toast.kind == ToastKind::error ? IM_COL32(239, 68, 68, 255) : + IM_COL32(82, 151, 245, 255); + const ImVec4 accent = ImGui::ColorConvertU32ToFloat4(accent_u32); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.11f, 0.12f, 0.15f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, accent); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(accent.x, accent.y, accent.z, 0.12f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(accent.x, accent.y, accent.z, 0.24f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(accent.x, accent.y, accent.z, 0.30f)); + + const std::string window_name = "##toast_" + std::to_string(toast.id); + ImGui::Begin(window_name.c_str(), nullptr, + ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing); + + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max = {min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()}; + ImGui::GetWindowDrawList()->AddRectFilled(min, {min.x + ui(4.0f), max.y}, accent_u32, ui(7.0f), + ImDrawFlags_RoundCornersLeft); + + ImGui::PushFont(g_bold_font, 0.0f); + ImGui::TextColored(accent, "%s", toast.title.c_str()); + ImGui::PopFont(); + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetWindowWidth() - ui(70.0f)); + if (ImGui::SmallButton((ICON_TB_COPY "##toast_copy_" + std::to_string(toast.id)).c_str())) + copy_to_clipboard(toast.message, "notification"); + ImGui::SameLine(); + bool dismiss = ImGui::SmallButton((ICON_TB_XMARK "##toast_close_" + std::to_string(toast.id)).c_str()); + + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + width - ui(26.0f)); + ImGui::TextUnformatted(toast.message.c_str()); + ImGui::PopTextWrapPos(); + + const float progress = std::clamp(toast.remaining / + (toast.kind == ToastKind::error ? 9.0f : (toast.kind == ToastKind::warning ? 7.0f : 5.0f)), 0.0f, 1.0f); + const ImVec2 progress_min = {min.x + ui(10.0f), max.y - ui(5.0f)}; + const ImVec2 progress_max = {max.x - ui(10.0f), max.y - ui(3.0f)}; + ImGui::GetWindowDrawList()->AddRectFilled(progress_min, progress_max, IM_COL32(255, 255, 255, 18), ui(2.0f)); + ImGui::GetWindowDrawList()->AddRectFilled(progress_min, + {progress_min.x + (progress_max.x - progress_min.x) * progress, progress_max.y}, accent_u32, ui(2.0f)); + + toast.hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + if (!toast.hovered) toast.remaining -= delta; + y += ImGui::GetWindowHeight() + ui(8.0f); + + ImGui::End(); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(3); + + if (dismiss || toast.remaining <= 0.0f) { + g_toasts.erase(g_toasts.begin() + static_cast(index)); + continue; + } + ++index; + } +} + bool start_branch_checkout_async(RepositoryView& repository, const std::string& branch) { GitAsyncRequest request; request.operation = GitAsyncOperation::checkout_branch; @@ -3912,6 +4074,7 @@ int runGitree(int argc, char** argv) { ImGui::NewFrame(); avatar_cache.update(); draw_app(); + draw_toasts(); ImGui::Render(); int width = 0, height = 0; glfwGetFramebufferSize(window_manager.nativeWindow(), &width, &height);