feat(ui): add hover-paused toast notifications

This commit is contained in:
2026-06-19 00:18:18 -05:00
parent 6361002f53
commit 5ac621d7b5

View File

@@ -34,6 +34,7 @@
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
@@ -155,6 +156,21 @@ struct GitAsyncResult {
std::future<GitAsyncResult> 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<ToastNotification> 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<std::ptrdiff_t>(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);