feat(ui): add hover-paused toast notifications
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user