feat(ui): refine graph, viewer, and repository workflows

This commit is contained in:
2026-06-18 23:03:20 -05:00
parent fefc2084ac
commit a8bec3ed22
15 changed files with 2063 additions and 505 deletions

View File

@@ -81,6 +81,7 @@ target_link_libraries(gitree PRIVATE imgui libgit2package iZo::izo ikv::ikv Open
target_compile_definitions(gitree PRIVATE
GITREE_VERSION="${PROJECT_VERSION}"
GITREE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/vendor/fonts"
GITREE_IMAGE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
$<$<PLATFORM_ID:Windows>:NOMINMAX;WIN32_LEAN_AND_MEAN>
)

View File

@@ -179,7 +179,14 @@ void GitManager::readBranches(RepositoryView &repository, git_branch_t type,
{
const char *name = nullptr;
if (git_branch_name(&name, reference) == 0 && name)
{
if (type == GIT_BRANCH_REMOTE && std::string_view(name).ends_with("/HEAD"))
{
git_reference_free(reference);
continue;
}
output.emplace_back(name);
}
git_reference_free(reference);
}
git_branch_iterator_free(iterator);
@@ -297,23 +304,21 @@ void GitManager::computeGraphLanes(RepositoryView &repository)
struct Lane
{
git_oid expected{};
int color = 0;
};
std::vector<Lane> lanes;
int next_color = 0;
for (auto &commit : repository.commits)
{
auto current = std::find_if(lanes.begin(), lanes.end(), [&commit](const Lane &lane)
{ return git_oid_equal(&lane.expected, &commit.oid) != 0; });
if (current == lanes.end())
{
lanes.push_back({commit.oid, next_color++});
lanes.push_back({commit.oid});
current = std::prev(lanes.end());
}
const size_t commit_lane = static_cast<size_t>(std::distance(lanes.begin(), current));
size_t active_lane = commit_lane;
commit.lane = static_cast<int>(commit_lane);
commit.graph_color = current->color;
commit.graph_color = commit.lane;
for (size_t duplicate = lanes.size(); duplicate-- > 0;)
{
if (duplicate != active_lane &&
@@ -347,7 +352,7 @@ void GitManager::computeGraphLanes(RepositoryView &repository)
if (found == lanes.end())
lanes.insert(lanes.begin() + static_cast<std::ptrdiff_t>(
std::min(active_lane + parent, lanes.size())),
{commit.parent_ids[parent], next_color++});
{commit.parent_ids[parent]});
}
}
}
@@ -571,10 +576,15 @@ bool GitManager::openRepository(RepositoryView &repository, const std::string &p
return loadRepositoryData(repository, error);
}
bool GitManager::initializeRepository(RepositoryView &repository, const std::string &path, std::string &error)
bool GitManager::initializeRepository(RepositoryView &repository, const std::string &path,
const std::string &initial_branch, std::string &error)
{
git_repository *created = nullptr;
if (git_repository_init(&created, path.c_str(), 0) != 0)
git_repository_init_options options{};
git_repository_init_options_init(&options, GIT_REPOSITORY_INIT_OPTIONS_VERSION);
options.flags = GIT_REPOSITORY_INIT_MKPATH;
options.initial_head = initial_branch.empty() ? "main" : initial_branch.c_str();
if (git_repository_init_ext(&created, path.c_str(), &options) != 0)
{
error = lastError("Unable to initialize repository");
return false;
@@ -583,6 +593,24 @@ bool GitManager::initializeRepository(RepositoryView &repository, const std::str
return openRepository(repository, path, error);
}
bool GitManager::cloneRepository(RepositoryView &repository, const std::string &url,
const std::string &path, bool shallow, std::string &error)
{
git_clone_options options{};
git_clone_options_init(&options, GIT_CLONE_OPTIONS_VERSION);
if (shallow)
options.fetch_opts.depth = 1;
git_repository *cloned = nullptr;
if (git_clone(&cloned, url.c_str(), path.c_str(), &options) != 0)
{
error = lastError("Unable to clone repository");
return false;
}
repository.close();
repository.repo = cloned;
return loadRepositoryData(repository, error);
}
bool GitManager::reload(RepositoryView &repository, std::string &error)
{
return repository.repo && loadRepositoryData(repository, error);
@@ -813,6 +841,54 @@ bool GitManager::push(RepositoryView &repository, std::string &error)
"Push complete; upstream configured", error);
}
bool GitManager::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error)
{
if (!prepareCredentials(repository, error))
return false;
git_reference *reference = nullptr;
if (git_branch_lookup(&reference, repository.repo, branch.c_str(), GIT_BRANCH_LOCAL) != 0)
{
error = lastError("Unable to find branch");
return false;
}
std::string remote_name;
std::string remote_branch_name;
git_reference *upstream = nullptr;
if (git_branch_upstream(&upstream, reference) == 0)
{
const char *upstream_name = nullptr;
if (git_branch_name(&upstream_name, upstream) == 0 && upstream_name)
{
remote_branch_name = upstream_name;
const size_t slash = remote_branch_name.find('/');
remote_name = slash == std::string::npos ? remote_branch_name : remote_branch_name.substr(0, slash);
if (slash != std::string::npos)
remote_branch_name.erase(0, slash + 1);
}
git_reference_free(upstream);
}
git_reference_free(reference);
if (!remote_name.empty() && !remote_branch_name.empty())
return runGit(repository, {"push", remote_name, branch + ":" + remote_branch_name},
"Push complete", error);
if (repository.remotes.empty())
{
error = "No remote is configured for this repository";
return false;
}
const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") !=
repository.remotes.end()
? "origin"
: repository.remotes.front();
return runGit(repository, {"push", "--set-upstream", remote, branch + ":" + branch},
"Push complete; upstream configured", error);
}
bool GitManager::stash(RepositoryView &repository, std::string &error)
{
return runGit(repository, {"stash", "push", "--include-untracked", "-m", "Gitree stash"},
@@ -839,6 +915,16 @@ bool GitManager::stageAll(RepositoryView &repository, std::string &error)
return runGit(repository, {"add", "--all"}, "All changes staged", error);
}
bool GitManager::unstageAll(RepositoryView &repository, std::string &error)
{
if (git_repository_head_unborn(repository.repo) == 1)
return runGit(repository, {"rm", "--cached", "-r", "--ignore-unmatch", "--", "."},
"All changes unstaged", error);
if (runGit(repository, {"restore", "--staged", "--", "."}, "All changes unstaged", error))
return true;
return runGit(repository, {"reset", "HEAD", "--", "."}, "All changes unstaged", error);
}
bool GitManager::stageFile(RepositoryView &repository, const std::string &path, std::string &error)
{
return runGit(repository, {"add", "--", path}, "File staged", error);
@@ -846,6 +932,9 @@ bool GitManager::stageFile(RepositoryView &repository, const std::string &path,
bool GitManager::unstageFile(RepositoryView &repository, const std::string &path, std::string &error)
{
if (git_repository_head_unborn(repository.repo) == 1)
return runGit(repository, {"rm", "--cached", "--ignore-unmatch", "--", path},
"File unstaged", error);
if (runGit(repository, {"restore", "--staged", "--", path}, "File unstaged", error))
return true;
return runGit(repository, {"reset", "HEAD", "--", path}, "File unstaged", error);

View File

@@ -11,18 +11,23 @@ public:
~GitManager();
bool openRepository(RepositoryView &repository, const std::string &path, std::string &error);
bool initializeRepository(RepositoryView &repository, const std::string &path, std::string &error);
bool initializeRepository(RepositoryView &repository, const std::string &path,
const std::string &initial_branch, std::string &error);
bool cloneRepository(RepositoryView &repository, const std::string &url,
const std::string &path, bool shallow, std::string &error);
bool reload(RepositoryView &repository, std::string &error);
bool loadMoreCommits(RepositoryView &repository, size_t page_size, std::string &error);
bool checkoutBranch(RepositoryView &repository, const std::string &branch, std::string &error);
bool fetch(RepositoryView &repository, const std::string &remote, std::string &error);
bool pull(RepositoryView &repository, int mode, std::string &error);
bool push(RepositoryView &repository, std::string &error);
bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error);
bool stash(RepositoryView &repository, std::string &error);
bool popStash(RepositoryView &repository, std::string &error);
bool undoCommit(RepositoryView &repository, std::string &error);
bool redoCommit(RepositoryView &repository, std::string &error);
bool stageAll(RepositoryView &repository, std::string &error);
bool unstageAll(RepositoryView &repository, std::string &error);
bool stageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool unstageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool discardFile(RepositoryView &repository, const std::string &path, std::string &error);

View File

@@ -56,6 +56,8 @@ void UserData::load()
details_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT))
pull_mode_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
zoom_percent_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
@@ -66,7 +68,7 @@ void UserData::load()
}
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 12);
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(history, index));
@@ -91,6 +93,7 @@ void UserData::load()
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
if (open_repositories_.empty())
@@ -111,6 +114,8 @@ void UserData::load()
settings >> details_width_;
else if (key == "pull_mode")
settings >> pull_mode_;
else if (key == "zoom_percent")
settings >> zoom_percent_;
else if (key.rfind("sidebar_section_", 0) == 0)
{
const size_t index = static_cast<size_t>(std::stoul(key.substr(16)));
@@ -123,6 +128,7 @@ void UserData::load()
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
@@ -132,7 +138,7 @@ void UserData::load()
{
if (!path.empty())
recently_closed_.push_back(path);
if (recently_closed_.size() == 12)
if (recently_closed_.size() == 100)
break;
}
@@ -152,11 +158,21 @@ void UserData::addRecentlyClosed(const std::string &path)
return;
std::erase(recently_closed_, path);
recently_closed_.insert(recently_closed_.begin(), path);
if (recently_closed_.size() > 12)
recently_closed_.resize(12);
if (recently_closed_.size() > 100)
recently_closed_.resize(100);
save();
}
std::string UserData::takeRecentlyClosed()
{
if (recently_closed_.empty())
return {};
std::string path = std::move(recently_closed_.front());
recently_closed_.erase(recently_closed_.begin());
save();
return path;
}
void UserData::setRepositorySession(std::vector<std::string> paths, size_t active_repository)
{
open_repositories_ = std::move(paths);
@@ -177,6 +193,7 @@ void UserData::save() const
ikv_object_set_float(settings, "sidebar_width", sidebar_width_);
ikv_object_set_float(settings, "details_width", details_width_);
ikv_object_set_int(settings, "pull_mode", pull_mode_);
ikv_object_set_int(settings, "zoom_percent", zoom_percent_);
ikv_node_t *heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT);
for (const float height : sidebar_section_heights_)
ikv_array_add_float(heights, height);

View File

@@ -20,6 +20,7 @@ public:
[[nodiscard]] float detailsWidth() const { return details_width_; }
[[nodiscard]] float sidebarSectionHeight(size_t index) const { return sidebar_section_heights_.at(index); }
[[nodiscard]] int pullMode() const { return pull_mode_; }
[[nodiscard]] int zoomPercent() const { return zoom_percent_; }
void setSidebarWidth(float width) { sidebar_width_ = width; }
void setDetailsWidth(float width) { details_width_ = width; }
@@ -29,7 +30,13 @@ public:
pull_mode_ = mode;
save();
}
void setZoomPercent(int percent)
{
zoom_percent_ = percent;
save();
}
void addRecentlyClosed(const std::string &path);
std::string takeRecentlyClosed();
void setRepositorySession(std::vector<std::string> paths, size_t active_repository);
void save() const;
@@ -45,4 +52,5 @@ private:
float details_width_ = 368.0f;
std::array<float, 4> sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f};
int pull_mode_ = 1;
int zoom_percent_ = 100;
};

View File

@@ -2,20 +2,136 @@
#include <GLFW/glfw3.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <filesystem>
#include <vector>
#ifdef _WIN32
#define GLFW_EXPOSE_NATIVE_WIN32
#include <GLFW/glfw3native.h>
#include <dwmapi.h>
#include <windows.h>
#include <wincodec.h>
#endif
WindowManager::~WindowManager() {
#ifdef _WIN32
namespace
{
HICON loadPngIcon(const std::filesystem::path &path, UINT size, bool rounded)
{
const HRESULT com_result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
const bool uninitialize_com = SUCCEEDED(com_result);
IWICImagingFactory *factory = nullptr;
IWICBitmapDecoder *decoder = nullptr;
IWICBitmapFrameDecode *frame = nullptr;
IWICBitmapScaler *scaler = nullptr;
IWICFormatConverter *converter = nullptr;
HICON icon = nullptr;
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&factory))) ||
FAILED(factory->CreateDecoderFromFilename(path.c_str(), nullptr, GENERIC_READ,
WICDecodeMetadataCacheOnLoad, &decoder)) ||
FAILED(decoder->GetFrame(0, &frame)) ||
FAILED(factory->CreateBitmapScaler(&scaler)) ||
FAILED(scaler->Initialize(frame, size, size, WICBitmapInterpolationModeFant)) ||
FAILED(factory->CreateFormatConverter(&converter)) ||
FAILED(converter->Initialize(scaler, GUID_WICPixelFormat32bppBGRA,
WICBitmapDitherTypeNone, nullptr, 0.0,
WICBitmapPaletteTypeCustom)))
{
goto cleanup;
}
{
std::vector<BYTE> pixels(static_cast<size_t>(size) * size * 4);
if (FAILED(converter->CopyPixels(nullptr, size * 4,
static_cast<UINT>(pixels.size()), pixels.data())))
{
goto cleanup;
}
if (rounded)
{
const float radius = static_cast<float>(size) * 0.22f;
const float maximum = static_cast<float>(size) - radius - 0.5f;
const float minimum = radius - 0.5f;
for (UINT y = 0; y < size; ++y)
{
for (UINT x = 0; x < size; ++x)
{
const float nearest_x = std::clamp(static_cast<float>(x), minimum, maximum);
const float nearest_y = std::clamp(static_cast<float>(y), minimum, maximum);
const float dx = static_cast<float>(x) - nearest_x;
const float dy = static_cast<float>(y) - nearest_y;
const float coverage = std::clamp(radius + 0.5f - std::sqrt(dx * dx + dy * dy),
0.0f, 1.0f);
pixels[(static_cast<size_t>(y) * size + x) * 4 + 3] =
static_cast<BYTE>(coverage * 255.0f);
}
}
}
BITMAPV5HEADER header{};
header.bV5Size = sizeof(header);
header.bV5Width = static_cast<LONG>(size);
header.bV5Height = -static_cast<LONG>(size);
header.bV5Planes = 1;
header.bV5BitCount = 32;
header.bV5Compression = BI_BITFIELDS;
header.bV5RedMask = 0x00ff0000;
header.bV5GreenMask = 0x0000ff00;
header.bV5BlueMask = 0x000000ff;
header.bV5AlphaMask = 0xff000000;
void *bitmap_bits = nullptr;
const HDC screen = GetDC(nullptr);
const HBITMAP color = CreateDIBSection(screen, reinterpret_cast<BITMAPINFO *>(&header),
DIB_RGB_COLORS, &bitmap_bits, nullptr, 0);
ReleaseDC(nullptr, screen);
const HBITMAP mask = CreateBitmap(size, size, 1, 1, nullptr);
if (color && mask && bitmap_bits)
{
std::memcpy(bitmap_bits, pixels.data(), pixels.size());
ICONINFO info{};
info.fIcon = TRUE;
info.hbmColor = color;
info.hbmMask = mask;
icon = CreateIconIndirect(&info);
}
if (color)
DeleteObject(color);
if (mask)
DeleteObject(mask);
}
cleanup:
if (converter)
converter->Release();
if (scaler)
scaler->Release();
if (frame)
frame->Release();
if (decoder)
decoder->Release();
if (factory)
factory->Release();
if (uninitialize_com)
CoUninitialize();
return icon;
}
} // namespace
#endif
WindowManager::~WindowManager()
{
destroy();
}
bool WindowManager::create(const char* title, int width, int height) {
bool WindowManager::create(const char *title, int width, int height)
{
#ifdef _WIN32
using SetDpiAwarenessContext = BOOL(WINAPI *)(HANDLE);
const HMODULE user32 = GetModuleHandleW(L"user32.dll");
@@ -23,15 +139,18 @@ bool WindowManager::create(const char* title, int width, int height) {
SetDpiAwarenessContext set_dpi_awareness = nullptr;
static_assert(sizeof(set_dpi_awareness) == sizeof(dpi_address));
std::memcpy(&set_dpi_awareness, &dpi_address, sizeof(set_dpi_awareness));
if (set_dpi_awareness) set_dpi_awareness(reinterpret_cast<HANDLE>(-4)); // Per-monitor v2
if (set_dpi_awareness)
set_dpi_awareness(reinterpret_cast<HANDLE>(-4)); // Per-monitor v2
#endif
if (!glfwInit()) return false;
if (!glfwInit())
return false;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_SCALE_TO_MONITOR, GLFW_TRUE);
window_ = glfwCreateWindow(width, height, title, nullptr, nullptr);
if (!window_) {
if (!window_)
{
glfwTerminate();
return false;
}
@@ -45,13 +164,17 @@ bool WindowManager::create(const char* title, int width, int height) {
glfwGetWindowContentScale(window_, &x_scale, &y_scale);
dpi_scale_ = std::max(x_scale, y_scale);
applyNativeCaption();
applyNativeIcons();
return true;
}
void WindowManager::destroy() {
if (!window_) return;
void WindowManager::destroy()
{
if (!window_)
return;
glfwDestroyWindow(window_);
window_ = nullptr;
destroyNativeIcons();
glfwTerminate();
}
@@ -60,39 +183,126 @@ void WindowManager::swapBuffers() { glfwSwapBuffers(window_); }
void WindowManager::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); }
bool WindowManager::shouldClose() const { return !window_ || glfwWindowShouldClose(window_); }
bool WindowManager::consumeDpiChange() {
bool WindowManager::consumeDpiChange()
{
const bool changed = dpi_changed_;
dpi_changed_ = false;
return changed;
}
void WindowManager::contentScaleCallback(GLFWwindow* window, float x_scale, float y_scale) {
void WindowManager::contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale)
{
auto *manager = static_cast<WindowManager *>(glfwGetWindowUserPointer(window));
if (manager) manager->updateDpi(std::max(x_scale, y_scale));
if (manager)
manager->updateDpi(std::max(x_scale, y_scale));
}
void WindowManager::updateDpi(float scale) {
void WindowManager::updateDpi(float scale)
{
scale = std::clamp(scale, 1.0f, 4.0f);
if (scale == dpi_scale_) return;
if (scale == dpi_scale_)
return;
dpi_scale_ = scale;
dpi_changed_ = true;
applyNativeCaption();
}
void WindowManager::applyNativeCaption() const {
void WindowManager::applyNativeCaption() const
{
#ifdef _WIN32
if (!window_)
return;
const HWND hwnd = glfwGetWin32Window(window_);
if (!hwnd)
return;
const BOOL dark = TRUE;
DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark)); // DWMWA_USE_IMMERSIVE_DARK_MODE
const DWORD square_corners = 1; // DWMWCP_DONOTROUND
DwmSetWindowAttribute(hwnd, 33, &square_corners, sizeof(square_corners));
// DWMWA_USE_IMMERSIVE_DARK_MODE
DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark));
DWORD corner_pref = 0;
switch (corner_mode_)
{
case WindowCornerMode::Default:
corner_pref = 0; // DWMWCP_DEFAULT
break;
case WindowCornerMode::DoNotRound:
corner_pref = 1; // DWMWCP_DONOTROUND
break;
case WindowCornerMode::Round:
corner_pref = 2; // DWMWCP_ROUND
break;
case WindowCornerMode::RoundSmall:
corner_pref = 3; // DWMWCP_ROUNDSMALL
break;
}
// DWMWA_WINDOW_CORNER_PREFERENCE
DwmSetWindowAttribute(hwnd, 33, &corner_pref, sizeof(corner_pref));
// Windows 11 caption customization. Older versions safely ignore these.
const COLORREF caption = RGB(32, 32, 32);
const COLORREF caption = static_cast<COLORREF>(caption_color_);
const COLORREF border = RGB(51, 55, 63);
const COLORREF text = RGB(199, 203, 209);
DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption)); // DWMWA_CAPTION_COLOR
DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border)); // DWMWA_BORDER_COLOR
DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text)); // DWMWA_TEXT_COLOR
// DWMWA_BORDER_COLOR
DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border));
// DWMWA_CAPTION_COLOR
DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption));
// DWMWA_TEXT_COLOR
DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text));
#endif
}
void WindowManager::applyNativeIcons()
{
#ifdef _WIN32
destroyNativeIcons();
const HWND hwnd = glfwGetWin32Window(window_);
const auto asset_dir = std::filesystem::path(GITREE_IMAGE_ASSET_DIR);
window_icon_ = loadPngIcon(asset_dir / L"gitree_logo_no_bg.png", 32, false);
taskbar_icon_ = loadPngIcon(asset_dir / L"gitree_logo.png", 64, true);
if (window_icon_)
{
SendMessageW(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(window_icon_));
SendMessageW(hwnd, WM_SETICON, ICON_SMALL2, reinterpret_cast<LPARAM>(window_icon_));
}
if (taskbar_icon_)
{
SendMessageW(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(taskbar_icon_));
}
#endif
}
void WindowManager::destroyNativeIcons()
{
#ifdef _WIN32
if (window_icon_)
DestroyIcon(static_cast<HICON>(window_icon_));
if (taskbar_icon_)
DestroyIcon(static_cast<HICON>(taskbar_icon_));
#endif
window_icon_ = nullptr;
taskbar_icon_ = nullptr;
}
void WindowManager::setCornerMode(WindowCornerMode mode)
{
corner_mode_ = mode;
applyNativeCaption();
}
void WindowManager::setCaptionColor(std::uint32_t color)
{
caption_color_ = color;
applyNativeCaption();
}

View File

@@ -1,9 +1,20 @@
#pragma once
#include <cstdint>
struct GLFWwindow;
class WindowManager
{
public:
enum class WindowCornerMode
{
Default,
DoNotRound,
Round,
RoundSmall
};
public:
WindowManager() = default;
~WindowManager();
@@ -16,17 +27,36 @@ public:
void pollEvents();
void swapBuffers();
void requestClose();
[[nodiscard]] bool shouldClose() const;
[[nodiscard]] GLFWwindow *nativeWindow() const { return window_; }
[[nodiscard]] float dpiScale() const { return dpi_scale_; }
bool consumeDpiChange();
void setCornerMode(WindowCornerMode mode);
[[nodiscard]] WindowCornerMode cornerMode() const { return corner_mode_; }
void setCaptionColor(std::uint32_t color);
[[nodiscard]] std::uint32_t captionColor() const { return caption_color_; }
private:
static void contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale);
void updateDpi(float scale);
void applyNativeCaption() const;
void applyNativeIcons();
void destroyNativeIcons();
GLFWwindow *window_ = nullptr;
void *window_icon_ = nullptr;
void *taskbar_icon_ = nullptr;
float dpi_scale_ = 1.0f;
bool dpi_changed_ = false;
WindowCornerMode corner_mode_ = WindowCornerMode::Round;
// 0x00BBGGRR, same layout Windows COLORREF uses.
std::uint32_t caption_color_ = 0x00201B19;
};

View File

@@ -1,13 +1,15 @@
#include "ui/diff_viewer.h"
#include "managers/avatar_cache.h"
#include "managers/git_manager.h"
#include "models/repository.h"
#include <IconsFontAwesome6.h>
#include <IconsTabler.h>
#include <imgui.h>
#include <algorithm>
#include <cctype>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
@@ -48,7 +50,7 @@ struct SyntaxState {
SyntaxContinuation continuation = SyntaxContinuation::none;
};
ImU32 blameColor(std::string_view hash, int alpha = 255) {
[[maybe_unused]] ImU32 blameColor(std::string_view hash, int alpha = 255) {
static constexpr ImU32 colors[] = {
IM_COL32(24, 181, 204, 255), IM_COL32(73, 123, 235, 255), IM_COL32(200, 64, 200, 255),
IM_COL32(239, 79, 89, 255), IM_COL32(255, 122, 41, 255), IM_COL32(240, 186, 46, 255),
@@ -59,7 +61,7 @@ ImU32 blameColor(std::string_view hash, int alpha = 255) {
return (colors[value % std::size(colors)] & ~IM_COL32_A_MASK) | (static_cast<ImU32>(alpha) << IM_COL32_A_SHIFT);
}
SyntaxLanguage languageForPath(const std::string& path) {
[[maybe_unused]] SyntaxLanguage languageForPath(const std::string& path) {
std::string extension = std::filesystem::path(path).extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
@@ -108,7 +110,49 @@ bool isTypeWord(SyntaxLanguage language, std::string_view word) {
return wordIs(word, {"bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "int8_t", "int16_t", "int32_t", "int64_t", "long", "nint", "nuint", "object", "sbyte", "short", "signed", "size_t", "string", "uint", "uint8_t", "uint16_t", "uint32_t", "uint64_t", "ulong", "unsigned", "ushort", "var", "void", "wchar_t"});
}
void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
bool isBuiltin(SyntaxLanguage language, std::string_view word) {
switch (language) {
case SyntaxLanguage::cpp:
return wordIs(word, {"std", "move", "forward", "make_shared", "make_unique", "optional", "string_view", "vector"});
case SyntaxLanguage::csharp:
return wordIs(word, {"Console", "DateTime", "Dictionary", "Enumerable", "List", "Task", "ValueTask"});
case SyntaxLanguage::lua:
return wordIs(word, {"assert", "error", "ipairs", "io", "math", "os", "pairs", "pcall", "print", "require", "string", "table", "tonumber", "tostring", "type"});
case SyntaxLanguage::python:
return wordIs(word, {"abs", "all", "any", "dict", "enumerate", "filter", "float", "int", "len", "list", "map", "max", "min", "object", "open", "print", "range", "reversed", "set", "str", "sum", "super", "tuple", "zip"});
case SyntaxLanguage::javascript:
return wordIs(word, {"Array", "Boolean", "console", "Date", "Error", "JSON", "Map", "Math", "Number", "Object", "process", "Promise", "RegExp", "require", "Set", "String", "Symbol"});
default:
return false;
}
}
bool isLiteral(std::string_view word) {
return wordIs(word, {"false", "False", "nil", "null", "nullptr", "None", "true", "True", "undefined"});
}
bool isDeclarationKeyword(std::string_view word) {
return wordIs(word, {"class", "concept", "delegate", "enum", "interface", "namespace", "record", "struct", "type"});
}
bool isFunctionDeclarationKeyword(std::string_view word) {
return word == "def" || word == "function";
}
bool isMacroName(std::string_view word) {
bool has_letter = false;
for (const unsigned char character : word) {
if (std::isalpha(character)) {
has_letter = true;
if (std::islower(character)) return false;
} else if (!std::isdigit(character) && character != '_') {
return false;
}
}
return has_letter && word.size() > 1;
}
[[maybe_unused]] void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
SyntaxLanguage language, SyntaxState& state) {
constexpr ImU32 normal = IM_COL32(218, 221, 226, 255);
constexpr ImU32 keyword = IM_COL32(198, 139, 230, 255);
@@ -118,6 +162,12 @@ void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
constexpr ImU32 comment = IM_COL32(112, 153, 105, 255);
constexpr ImU32 function = IM_COL32(220, 199, 128, 255);
constexpr ImU32 preprocessor = IM_COL32(205, 157, 222, 255);
constexpr ImU32 member = IM_COL32(122, 184, 225, 255);
constexpr ImU32 builtin = IM_COL32(103, 172, 232, 255);
constexpr ImU32 constant = IM_COL32(214, 139, 102, 255);
constexpr ImU32 operator_color = IM_COL32(105, 180, 210, 255);
constexpr ImU32 decorator = IM_COL32(226, 190, 105, 255);
constexpr ImU32 macro = IM_COL32(215, 128, 180, 255);
const auto drawSpan = [&](size_t begin, size_t end, ImU32 color) {
if (end <= begin) return;
@@ -140,6 +190,7 @@ void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
}
size_t cursor = 0;
std::string_view previous_word;
while (cursor < text.size()) {
if (state.continuation == SyntaxContinuation::block_comment) {
const size_t end = text.find("*/", cursor);
@@ -249,15 +300,49 @@ void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
cursor = end;
continue;
}
if (text[cursor] == '@' && cursor + 1 < text.size() &&
(std::isalpha(static_cast<unsigned char>(text[cursor + 1])) || text[cursor + 1] == '_')) {
size_t end = cursor + 2;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) ||
text[end] == '_' || text[end] == '.')) ++end;
drawSpan(cursor, end, decorator);
cursor = end;
continue;
}
if (std::isalpha(static_cast<unsigned char>(text[cursor])) || text[cursor] == '_') {
size_t end = cursor + 1;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) || text[end] == '_')) ++end;
const std::string_view word(text.data() + cursor, end - cursor);
size_t next = end;
while (next < text.size() && std::isspace(static_cast<unsigned char>(text[next]))) ++next;
const ImU32 color = isKeyword(language, word) ? keyword : isTypeWord(language, word) ? type :
size_t previous = cursor;
while (previous > 0 && std::isspace(static_cast<unsigned char>(text[previous - 1]))) --previous;
const bool member_access = previous > 0 && (text[previous - 1] == '.' ||
(text[previous - 1] == '>' && previous > 1 && text[previous - 2] == '-') ||
(text[previous - 1] == ':' && previous > 1 && text[previous - 2] == ':'));
const bool declared_name = isDeclarationKeyword(previous_word);
const bool declared_function = isFunctionDeclarationKeyword(previous_word);
const bool capitalized_type =
(language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) &&
std::isupper(static_cast<unsigned char>(word.front())) && !member_access &&
(next >= text.size() || text[next] != '(');
const bool likely_type = declared_name || isTypeWord(language, word) ||
capitalized_type;
const ImU32 color = isLiteral(word) ? constant : isKeyword(language, word) ? keyword :
declared_function ? function : declared_name || likely_type ? type :
isMacroName(word) ? macro : isBuiltin(language, word) ? builtin : member_access ?
(next < text.size() && text[next] == '(' ? function : member) :
next < text.size() && text[next] == '(' ? function : normal;
drawSpan(cursor, end, color);
previous_word = word;
cursor = end;
continue;
}
if (std::string_view("+-*/%=!<>&|^~?:").find(text[cursor]) != std::string_view::npos) {
size_t end = cursor + 1;
while (end < text.size() &&
std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos) ++end;
drawSpan(cursor, end, operator_color);
cursor = end;
continue;
}
@@ -267,7 +352,8 @@ void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
const bool token_start = std::isalnum(value) || text[end] == '_' || text[end] == '\'' ||
text[end] == '"' || (language == SyntaxLanguage::javascript && text[end] == '`') ||
(slash_comments && text[end] == '/') || (language == SyntaxLanguage::python && text[end] == '#') ||
(language == SyntaxLanguage::lua && text[end] == '-');
(language == SyntaxLanguage::lua && text[end] == '-') || text[end] == '@' ||
std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos;
if (token_start) break;
++end;
}
@@ -278,10 +364,77 @@ void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
bool compactButton(const char* label, bool active = false) {
if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.25f, 0.43f, 1.0f));
const bool clicked = ImGui::SmallButton(label);
const bool icon_only = label && std::strlen(label) <= 4;
const bool clicked = icon_only
? ImGui::Button(label, {ImGui::GetFrameHeight(), ImGui::GetFrameHeight()})
: ImGui::SmallButton(label);
if (active) ImGui::PopStyleColor();
return clicked;
}
std::string joinLines(const std::vector<std::string>& lines) {
std::string text;
for (size_t index = 0; index < lines.size(); ++index) {
if (index) text += '\n';
text += lines[index];
}
return text;
}
bool drawSelectableTextBlock(const char* id, const std::string& text, const ImVec2& size) {
std::vector<char> buffer(text.begin(), text.end());
buffer.push_back('\0');
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.06f, 0.07f, 0.09f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.18f, 0.20f, 0.24f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 1.0f);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {4.0f, 4.0f});
const bool changed = ImGui::InputTextMultiline(id, buffer.data(), buffer.size(), size,
ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_AllowTabInput);
ImGui::PopStyleVar(2);
ImGui::PopStyleColor(2);
return changed;
}
struct MinimapEntry {
size_t length = 0;
ImU32 color = IM_COL32(120, 126, 136, 255);
};
void drawMinimap(const std::vector<MinimapEntry>& entries, float scale,
float visible_ratio, float scroll_ratio) {
const ImVec2 minimum = ImGui::GetCursorScreenPos();
const ImVec2 size = ImGui::GetContentRegionAvail();
ImGui::InvisibleButton("##code_minimap", size);
ImDrawList* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(minimum, {minimum.x + size.x, minimum.y + size.y}, IM_COL32(38, 40, 47, 255));
draw->AddRectFilled({minimum.x + size.x - scaled(10.0f, scale), minimum.y},
{minimum.x + size.x, minimum.y + size.y}, IM_COL32(64, 66, 74, 255));
if (entries.empty() || size.y <= 2.0f) return;
const float content_left = minimum.x + scaled(4.0f, scale);
const float content_right = minimum.x + size.x - scaled(14.0f, scale);
const float content_width = std::max(1.0f, content_right - content_left);
const float line_step = size.y / static_cast<float>(entries.size());
const float line_height = std::max(1.0f, std::min(scaled(2.0f, scale), line_step));
constexpr float max_reference_length = 160.0f;
for (size_t index = 0; index < entries.size(); ++index) {
const float normalized = std::clamp(static_cast<float>(entries[index].length) / max_reference_length, 0.08f, 1.0f);
const float width = content_width * normalized;
const float y = minimum.y + index * line_step;
draw->AddRectFilled({content_left, y}, {content_left + width, y + line_height},
entries[index].color, scaled(1.0f, scale));
}
const float viewport_height = std::clamp(size.y * visible_ratio, scaled(18.0f, scale), size.y);
const float viewport_y = minimum.y + (size.y - viewport_height) * std::clamp(scroll_ratio, 0.0f, 1.0f);
draw->AddRectFilled({minimum.x + scaled(1.0f, scale), viewport_y},
{minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height},
IM_COL32(120, 146, 198, 45), scaled(2.0f, scale));
draw->AddRect({minimum.x + scaled(1.0f, scale), viewport_y},
{minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height},
IM_COL32(120, 146, 198, 160), scaled(2.0f, scale));
}
}
void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std::string& path,
@@ -378,6 +531,11 @@ void DiffViewer::parseBlame(const std::string& text) {
const std::string_view key(field.data(), separator == std::string::npos ? field.size() : separator);
const std::string value = separator == std::string::npos ? std::string{} : field.substr(separator + 1);
if (key == "author") line.author = value;
else if (key == "author-mail") {
line.email = value;
if (line.email.size() >= 2 && line.email.front() == '<' && line.email.back() == '>')
line.email = line.email.substr(1, line.email.size() - 2);
}
else if (key == "author-time") {
try { author_time = static_cast<std::time_t>(std::stoll(value)); }
catch (...) { author_time = 0; }
@@ -474,11 +632,13 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager,
}
}
void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice) {
void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars,
float scale, ImFont* code_font, std::string& notice) {
(void)avatars;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {scaled(8, scale), scaled(5, scale)});
ImGui::BeginChild("diff_viewer", {-1, -1}, ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN);
ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_TB_PEN);
ImGui::SameLine(0, scaled(7, scale));
ImGui::TextUnformatted(path_.c_str());
@@ -512,7 +672,7 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
ImGui::PopStyleColor();
}
ImGui::SameLine();
if (compactButton(ICON_FA_XMARK)) close();
if (compactButton(ICON_TB_XMARK)) close();
ImGui::Separator();
if (!path_.empty()) {
ImGui::SetCursorPosX(ImGui::GetWindowWidth() - scaled(116, scale));
@@ -520,7 +680,7 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
if (!historical && !staged_) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1));
if (compactButton(ICON_FA_TRASH_CAN)) {
if (compactButton(ICON_TB_TRASH_CAN)) {
if (manager.discardFile(repository, path_, notice)) close();
}
ImGui::PopStyleColor();
@@ -528,37 +688,37 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
ImGui::Separator();
}
ImGui::BeginChild("diff_content", {-1, -1}, ImGuiChildFlags_None,
ImGuiWindowFlags_HorizontalScrollbar);
const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file;
const float minimap_width = scaled(56.0f, scale);
float main_scroll_y = 0.0f;
float main_scroll_max_y = 0.0f;
float main_window_height = 0.0f;
std::vector<MinimapEntry> minimap_entries;
if (show_minimap) {
if (mode_ == Mode::diff) {
for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) {
minimap_entries.push_back({hunks_[hunk_index].header.size(), IM_COL32(128, 133, 141, 255)});
for (const Line& line : hunks_[hunk_index].lines) {
const ImU32 color = line.kind == LineKind::added ? IM_COL32(87, 190, 112, 255) :
line.kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) :
IM_COL32(112, 118, 128, 255);
minimap_entries.push_back({line.text.size() + 1, color});
}
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
}
} else {
minimap_entries.reserve(file_lines_.size());
for (const std::string& line : file_lines_)
minimap_entries.push_back({line.size(), IM_COL32(112, 118, 128, 255)});
}
}
ImGui::BeginChild("diff_content_main",
show_minimap ? ImVec2{-minimap_width - scaled(6.0f, scale), -1} : ImVec2{-1, -1},
ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar);
const bool use_code_font = code_font && mode_ != Mode::history;
if (use_code_font) ImGui::PushFont(code_font, 0.0f);
const float row_height = scaled(21, scale);
const float number_width = scaled(48, scale);
const SyntaxLanguage language = languageForPath(path_);
auto draw_line = [&](const std::string& text, int old_number, int new_number, LineKind kind,
SyntaxLanguage line_language, SyntaxState& syntax_state) {
ImGui::InvisibleButton("##line", {std::max(ImGui::GetContentRegionAvail().x, scaled(900, scale)), row_height});
const ImVec2 minimum = ImGui::GetItemRectMin();
const ImVec2 maximum = ImGui::GetItemRectMax();
ImDrawList* draw = ImGui::GetWindowDrawList();
if (kind == LineKind::added) draw->AddRectFilled(minimum, maximum, IM_COL32(31, 65, 43, 225));
else if (kind == LineKind::removed) draw->AddRectFilled(minimum, maximum, IM_COL32(70, 38, 40, 225));
draw->AddRectFilled(minimum, {minimum.x + number_width * 2, maximum.y}, IM_COL32(31, 34, 40, 255));
char old_buffer[16]{};
char new_buffer[16]{};
if (old_number) std::snprintf(old_buffer, sizeof(old_buffer), "%d", old_number);
if (new_number) std::snprintf(new_buffer, sizeof(new_buffer), "%d", new_number);
draw->AddText({minimum.x + scaled(5, scale), minimum.y + scaled(2, scale)},
IM_COL32(158, 164, 174, 255), old_buffer);
draw->AddText({minimum.x + number_width + scaled(5, scale), minimum.y + scaled(2, scale)},
IM_COL32(158, 164, 174, 255), new_buffer);
const char marker = kind == LineKind::added ? '+' : kind == LineKind::removed ? '-' : ' ';
char marker_text[2]{marker, 0};
draw->AddText({minimum.x + number_width * 2 + scaled(5, scale), minimum.y + scaled(2, scale)},
kind == LineKind::added ? IM_COL32(87, 190, 112, 255) :
kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) : IM_COL32(148, 154, 164, 255), marker_text);
drawSyntaxText(draw,
{minimum.x + number_width * 2 + scaled(22, scale), minimum.y + scaled(2, scale)},
text, line_language, syntax_state);
};
// Keep the first source row clear of the toolbar and aligned with a normal row inset.
ImGui::Dummy({0.0f, row_height});
@@ -573,7 +733,12 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
SyntaxState old_syntax;
SyntaxState new_syntax;
ImGui::PushID(static_cast<int>(hunk_index));
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.56f, 0.70f, 0.90f, 1));
if (hunk_index > 0) {
ImGui::Dummy({0.0f, scaled(11.0f, scale)});
ImGui::Separator();
ImGui::Dummy({0.0f, scaled(8.0f, scale)});
}
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.62f, 0.64f, 0.68f, 1.0f));
ImGui::TextUnformatted(hunks_[hunk_index].header.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(std::max(scaled(220, scale), ImGui::GetWindowWidth() - scaled(205, scale)));
@@ -595,13 +760,17 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
pending_hunk = static_cast<int>(hunk_index);
pending_action = HunkAction::unstage;
}
for (const Line& line : hunks_[hunk_index].lines) {
ImGui::PushID(line_id++);
SyntaxState& syntax_state = line.kind == LineKind::removed ? old_syntax : new_syntax;
draw_line(line.text, line.old_number, line.new_number, line.kind, language, syntax_state);
if (line.kind == LineKind::context) old_syntax = new_syntax;
ImGui::PopID();
ImGui::Separator();
ImGui::Dummy({0.0f, scaled(4.0f, scale)});
std::string hunk_text;
for (size_t line_index = 0; line_index < hunks_[hunk_index].lines.size(); ++line_index) {
if (line_index) hunk_text += '\n';
hunk_text += hunks_[hunk_index].lines[line_index].raw;
}
const float hunk_height = std::max(row_height * 3.0f,
row_height * static_cast<float>(hunks_[hunk_index].lines.size()) + scaled(10.0f, scale));
drawSelectableTextBlock(("##diff_hunk_text" + std::to_string(line_id++)).c_str(),
hunk_text, {-1, hunk_height});
ImGui::PopID();
}
if (pending_hunk >= 0 && pending_hunk < static_cast<int>(hunks_.size())) {
@@ -611,71 +780,43 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
reload(repository, manager, notice);
}
} else if (mode_ == Mode::blame) {
SyntaxState syntax_state;
const float blame_number_width = scaled(42.0f, scale);
const float attribution_width = scaled(285.0f, scale);
std::ostringstream blame_text;
for (size_t index = 0; index < blame_lines_.size(); ++index) {
const BlameLine& line = blame_lines_[index];
ImGui::PushID(static_cast<int>(index));
ImGui::InvisibleButton("##blame_line",
{std::max(ImGui::GetContentRegionAvail().x, scaled(900.0f, scale)), row_height});
const ImVec2 minimum = ImGui::GetItemRectMin();
const ImVec2 maximum = ImGui::GetItemRectMax();
ImDrawList* draw = ImGui::GetWindowDrawList();
const ImU32 color = blameColor(line.hash);
draw->AddRectFilled(minimum,
{minimum.x + blame_number_width + attribution_width, maximum.y}, blameColor(line.hash, 18));
draw->AddRectFilled(
{minimum.x + blame_number_width + attribution_width - scaled(2.0f, scale), minimum.y},
{minimum.x + blame_number_width + attribution_width, maximum.y}, color);
char line_number[16]{};
std::snprintf(line_number, sizeof(line_number), "%d", line.line_number);
draw->AddText({minimum.x + scaled(6.0f, scale), minimum.y + scaled(2.0f, scale)},
IM_COL32(151, 158, 169, 255), line_number);
if (index) blame_text << '\n';
blame_text << line.line_number << " ";
if (line.show_attribution) {
const float attribution_left = minimum.x + blame_number_width + scaled(7.0f, scale);
const float attribution_right = minimum.x + blame_number_width + attribution_width - scaled(8.0f, scale);
draw->AddCircleFilled(
{attribution_left + scaled(4.0f, scale), minimum.y + row_height * 0.5f},
scaled(3.5f, scale), color);
const float date_width = ImGui::CalcTextSize(line.date.c_str()).x;
draw->PushClipRect(
{attribution_left + scaled(14.0f, scale), minimum.y},
{attribution_right - date_width - scaled(9.0f, scale), maximum.y}, true);
draw->AddText({attribution_left + scaled(14.0f, scale), minimum.y + scaled(2.0f, scale)},
IM_COL32(205, 211, 220, 255), line.summary.c_str());
draw->PopClipRect();
draw->AddText({attribution_right - date_width, minimum.y + scaled(2.0f, scale)},
IM_COL32(139, 147, 159, 255), line.date.c_str());
if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort) &&
ImGui::GetIO().MousePos.x < minimum.x + blame_number_width + attribution_width) {
ImGui::SetTooltip("%s\n%s\n%s %s",
line.summary.c_str(), line.author.c_str(), line.hash.substr(0, 10).c_str(), line.date.c_str());
blame_text << line.summary;
if (!line.date.empty()) blame_text << " " << line.date;
blame_text << " ";
} else {
blame_text << "| ";
}
blame_text << line.text;
}
drawSyntaxText(draw,
{minimum.x + blame_number_width + attribution_width + scaled(10.0f, scale),
minimum.y + scaled(2.0f, scale)},
line.text, language, syntax_state);
ImGui::PopID();
}
drawSelectableTextBlock("##blame_text", blame_text.str(), {-1, -1});
if (blame_lines_.empty()) ImGui::TextDisabled("No blame data is available for this file.");
} else {
const std::vector<std::string>* lines = mode_ == Mode::file ? &file_lines_ : &history_lines_;
SyntaxState syntax_state;
const SyntaxLanguage line_language = mode_ == Mode::file ? language : SyntaxLanguage::plain;
for (size_t index = 0; index < lines->size(); ++index) {
ImGui::PushID(static_cast<int>(index));
draw_line((*lines)[index], mode_ == Mode::file ? static_cast<int>(index + 1) : 0,
mode_ == Mode::file ? static_cast<int>(index + 1) : 0, LineKind::context,
line_language, syntax_state);
ImGui::PopID();
}
drawSelectableTextBlock(mode_ == Mode::file ? "##file_text" : "##history_text",
joinLines(*lines), {-1, -1});
if (lines->empty()) ImGui::TextDisabled("No data is available for this view.");
}
main_scroll_y = ImGui::GetScrollY();
main_scroll_max_y = ImGui::GetScrollMaxY();
main_window_height = ImGui::GetWindowHeight();
if (use_code_font) ImGui::PopFont();
ImGui::EndChild();
if (show_minimap) {
ImGui::SameLine(0, scaled(6.0f, scale));
ImGui::BeginChild("diff_content_minimap", {minimap_width, -1}, ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
const float visible_ratio = main_scroll_max_y <= 0.0f
? 1.0f : std::clamp(main_window_height / (main_window_height + main_scroll_max_y), 0.0f, 1.0f);
const float scroll_ratio = main_scroll_max_y <= 0.0f ? 0.0f : main_scroll_y / main_scroll_max_y;
drawMinimap(minimap_entries, scale, visible_ratio, scroll_ratio);
ImGui::EndChild();
}
ImGui::EndChild();
ImGui::PopStyleVar();
}

View File

@@ -4,7 +4,9 @@
#include <vector>
class GitManager;
class AvatarCache;
struct RepositoryView;
struct ImFont;
class DiffViewer {
public:
@@ -14,7 +16,8 @@ public:
const std::string& commit_id, std::string& notice);
void close();
bool isOpen() const { return !path_.empty(); }
void draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice);
void draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars,
float scale, ImFont* code_font, std::string& notice);
private:
enum class Mode { diff, file, blame, history };
@@ -34,6 +37,7 @@ private:
struct BlameLine {
std::string hash;
std::string author;
std::string email;
std::string date;
std::string summary;
std::string text;

File diff suppressed because it is too large Load Diff

View File

@@ -2,42 +2,95 @@
#include "managers/avatar_cache.h"
#include "models/repository.h"
#include <IconsFontAwesome6.h>
#include <IconsTabler.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
namespace {
void drawOrthogonalEdge(ImDrawList* draw, const ImVec2& child, const ImVec2& parent,
ImU32 color, float scale, int route_slot) {
const float thickness = 2.0f * scale;
const auto stroke = [&](ImU32 stroke_color, float stroke_thickness) {
template <size_t Count>
void drawRoundedPolyline(ImDrawList* draw, const std::array<ImVec2, Count>& points,
float radius, ImU32 color, float thickness) {
static_assert(Count >= 2);
draw->PathClear();
draw->PathLineTo(child);
draw->PathLineTo(points.front());
for (size_t index = 1; index + 1 < Count; ++index) {
const ImVec2 previous = points[index - 1];
const ImVec2 corner = points[index];
const ImVec2 next = points[index + 1];
const float incoming_length = std::hypot(corner.x - previous.x, corner.y - previous.y);
const float outgoing_length = std::hypot(next.x - corner.x, next.y - corner.y);
if (incoming_length < 0.01f || outgoing_length < 0.01f) continue;
const float turn_radius = std::min({radius, incoming_length * 0.5f, outgoing_length * 0.5f});
const ImVec2 incoming{
(corner.x - previous.x) / incoming_length,
(corner.y - previous.y) / incoming_length,
};
const ImVec2 outgoing{
(next.x - corner.x) / outgoing_length,
(next.y - corner.y) / outgoing_length,
};
const ImVec2 before{
corner.x - incoming.x * turn_radius,
corner.y - incoming.y * turn_radius,
};
const ImVec2 after{
corner.x + outgoing.x * turn_radius,
corner.y + outgoing.y * turn_radius,
};
constexpr float control = 0.55228475f;
draw->PathLineTo(before);
draw->PathBezierCubicCurveTo(
{before.x + incoming.x * turn_radius * control,
before.y + incoming.y * turn_radius * control},
{after.x - outgoing.x * turn_radius * control,
after.y - outgoing.y * turn_radius * control},
after);
}
draw->PathLineTo(points.back());
draw->PathStroke(color, ImDrawFlags_None, thickness);
}
void drawOrthogonalEdge(ImDrawList* draw, const ImVec2& child, const ImVec2& parent,
ImU32 color, float scale, int route_slot, float detour_x) {
const float thickness = 2.0f * scale;
const float horizontal = parent.x - child.x;
const float vertical = parent.y - child.y;
if (std::abs(horizontal) < 0.5f * scale || vertical <= 0.0f) {
draw->PathLineTo(parent);
draw->PathStroke(stroke_color, stroke_thickness);
if (vertical <= 0.0f) {
draw->AddLine(child, parent, color, thickness);
return;
}
// First-parent lane changes turn on the parent commit row. Additional merge
// parents leave horizontally on the child row. Both routes terminate as
// square T-junctions instead of bending through the front or back of a node.
if (route_slot == 0) {
draw->PathLineTo({child.x, parent.y});
draw->PathLineTo(parent);
if (std::isfinite(detour_x)) {
if (std::abs(detour_x - child.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, parent.y}, parent},
7.0f * scale, color, thickness);
} else if (std::abs(detour_x - parent.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, child.y}, parent},
7.0f * scale, color, thickness);
} else {
draw->PathLineTo({parent.x, child.y});
draw->PathLineTo(parent);
drawRoundedPolyline(draw,
std::array{child, ImVec2{detour_x, child.y}, ImVec2{detour_x, parent.y}, parent},
7.0f * scale, color, thickness);
}
return;
}
draw->PathStroke(stroke_color, stroke_thickness);
};
stroke(IM_COL32(17, 21, 27, 255), thickness + 2.4f * scale);
stroke(color, thickness);
if (std::abs(horizontal) < 0.5f * scale) {
draw->AddLine(child, parent, color, thickness);
return;
}
// Bends remain anchored to commit rows, but use a compact quarter-round turn.
if (route_slot == 0)
drawRoundedPolyline(draw, std::array{child, ImVec2{child.x, parent.y}, parent},
7.0f * scale, color, thickness);
else
drawRoundedPolyline(draw, std::array{child, ImVec2{parent.x, child.y}, parent},
7.0f * scale, color, thickness);
}
} // namespace
@@ -60,7 +113,8 @@ ImU32 GraphRenderer::laneColor(int lane, int alpha) {
float GraphRenderer::requiredWidth(const std::vector<CommitInfo>& commits, float scale) {
int maximum_lane = 0;
for (const auto& commit : commits) maximum_lane = std::max(maximum_lane, commit.lane);
return std::max((46.0f + maximum_lane * 22.0f) * scale, 60.0f * scale);
// Keep one spare lane available for edges that must route around occupied commit lanes.
return std::max((68.0f + maximum_lane * 22.0f) * scale, 82.0f * scale);
}
void GraphRenderer::drawRow(int row, const CommitInfo& commit,
@@ -86,20 +140,18 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
{ribbon_left, ribbon_top},
{cell_right, ribbon_bottom},
laneColor(commit.graph_color, 38));
draw->AddLine(
{cell_right - px(1.0f), ribbon_top},
{cell_right - px(1.0f), ribbon_bottom},
laneColor(commit.graph_color, 215), px(2.0f));
if (ref_connector_start) {
const ImVec2 end{x, y};
const float turn_x = end.x - px(8.0f);
const auto stroke_connector = [&](ImU32 color, float thickness) {
draw->PathClear();
draw->PathLineTo(*ref_connector_start);
draw->PathLineTo({turn_x, ref_connector_start->y});
draw->PathLineTo({turn_x, end.y});
draw->PathLineTo(end);
draw->PathStroke(color, thickness);
};
draw->PushClipRectFullScreen();
stroke_connector(IM_COL32(17, 21, 27, 255), px(3.8f));
stroke_connector(laneColor(commit.graph_color, 175), px(1.6f));
drawRoundedPolyline(draw,
std::array{*ref_connector_start, ImVec2{turn_x, ref_connector_start->y},
ImVec2{turn_x, end.y}, end},
px(5.0f), laneColor(commit.graph_color, 190), px(1.8f));
draw->PopClipRect();
}
@@ -131,11 +183,44 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
const float parent_x = origin.x + px(17.0f) + lane_spacing * parent.lane;
const float child_y = center_y(child_row);
const float parent_y = center_y(parent_row);
// An edge belongs to the branch it leaves, including its square lane transition,
// so its color stays consistent with the child node/ref chip.
const int edge_color = parent_index == 0 ? child.graph_color : parent.graph_color;
const int preferred_lane = parent_index == 0 ? child.lane : parent.lane;
const auto lane_is_blocked = [&](int candidate_lane) {
for (int intermediate = child_row + 1; intermediate < parent_row; ++intermediate) {
if (row_heights[static_cast<size_t>(intermediate)] > 0.0f &&
commits[static_cast<size_t>(intermediate)].lane == candidate_lane)
return true;
}
return false;
};
float detour_x = std::numeric_limits<float>::quiet_NaN();
if (lane_is_blocked(preferred_lane)) {
int maximum_lane = 0;
for (int intermediate = child_row; intermediate <= parent_row; ++intermediate) {
if (row_heights[static_cast<size_t>(intermediate)] > 0.0f)
maximum_lane = std::max(maximum_lane,
commits[static_cast<size_t>(intermediate)].lane);
}
int detour_lane = maximum_lane + 1;
for (int distance = 1; distance <= maximum_lane + 1; ++distance) {
const int left = preferred_lane - distance;
const int right = preferred_lane + distance;
if (left >= 0 && !lane_is_blocked(left)) {
detour_lane = left;
break;
}
if (right <= maximum_lane && !lane_is_blocked(right)) {
detour_lane = right;
break;
}
}
detour_x = origin.x + px(17.0f) + lane_spacing * detour_lane;
}
// Colors are lane-position based, so edges use the visible lane slot they route through.
const int edge_color = std::isfinite(detour_x)
? static_cast<int>(std::lround((detour_x - (origin.x + px(17.0f))) / lane_spacing))
: preferred_lane;
drawOrthogonalEdge(draw, {child_x, child_y}, {parent_x, parent_y},
laneColor(edge_color, 235), scale_, static_cast<int>(parent_index));
laneColor(edge_color, 235), scale_, static_cast<int>(parent_index), detour_x);
}
}
draw->PopClipRect();
@@ -171,8 +256,8 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
{x + radius - px(1.5f), y + radius - px(1.5f)},
{0, 0}, {1, 1}, IM_COL32_WHITE, radius);
} else {
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_FA_USER);
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_FA_USER);
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_TB_USER);
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_TB_USER);
}
show_identity_tooltip(radius + px(2.0f));
ImGui::Dummy({0.0f, content_height});

BIN
vendor/fonts/Inter-Regular.ttf vendored Normal file

Binary file not shown.

BIN
vendor/fonts/Inter-SemiBold.ttf vendored Normal file

Binary file not shown.

Binary file not shown.

59
vendor/icons/IconsTabler.h vendored Normal file
View File

@@ -0,0 +1,59 @@
#pragma once
// Tabler Icons 3.44.0 outline webfont glyphs used by Gitree.
#define ICON_MIN_TB 0xEA06
#define ICON_MAX_TB 0xFAF7
#define ICON_TB_ANGLE_RIGHT "\xee\xa9\xa1"
#define ICON_TB_ARROW_DOWN "\xee\xa8\x96"
#define ICON_TB_ARROW_DOWN_A_Z "\xee\xbc\x98"
#define ICON_TB_ARROW_DOWN_Z_A "\xee\xbc\x9a"
#define ICON_TB_ARROW_RIGHT_ARROW_LEFT "\xef\x87\xb4"
#define ICON_TB_ARROW_UP "\xee\xa8\xa5"
#define ICON_TB_ARROW_UP_RIGHT_FROM_SQUARE "\xee\xaa\x99"
#define ICON_TB_BARS "\xee\xb1\x82"
#define ICON_TB_BOX_ARCHIVE "\xee\xa8\x8b"
#define ICON_TB_BOX_OPEN "\xef\x81\xba"
#define ICON_TB_CARET_DOWN "\xee\xad\x9d"
#define ICON_TB_CHECK "\xee\xa9\x9e"
#define ICON_TB_CHEVRON_DOWN "\xee\xa9\x9f"
#define ICON_TB_CHEVRON_RIGHT "\xee\xa9\xa1"
#define ICON_TB_CIRCLE_CHEVRON_LEFT "\xef\x98\xa3"
#define ICON_TB_CIRCLE_CHEVRON_RIGHT "\xef\x98\xa4"
#define ICON_TB_CIRCLE_DOT "\xee\xbe\xb1"
#define ICON_TB_CIRCLE_NODES "\xee\xaa\xb5"
#define ICON_TB_CLOCK_ROTATE_LEFT "\xee\xaf\xaa"
#define ICON_TB_CLOUD "\xee\xa9\xb6"
#define ICON_TB_CODE "\xee\xa9\xb7"
#define ICON_TB_CODE_BRANCH "\xee\xaa\xb2"
#define ICON_TB_COMPUTER "\xee\xaa\x89"
#define ICON_TB_COPY "\xee\xa9\xba"
#define ICON_TB_CUBES "\xee\xb8\x97"
#define ICON_TB_DESKTOP "\xee\xaa\x89"
#define ICON_TB_DEVICE_LAPTOP "\xee\xad\xa4"
#define ICON_TB_DOWNLOAD "\xee\xaa\x96"
#define ICON_TB_EYE "\xee\xaa\x9a"
#define ICON_TB_FILE "\xee\xaa\xa4"
#define ICON_TB_FOLDER "\xee\xaa\xad"
#define ICON_TB_FOLDER_OPEN "\xef\xab\xb7"
#define ICON_TB_FOLDER_TREE "\xee\xba\x9d"
#define ICON_TB_GLOBE "\xee\xad\x94"
#define ICON_TB_JET_FIGHTER_UP "\xef\x87\xad"
#define ICON_TB_LAYERS_LINKED "\xee\xba\xa1"
#define ICON_TB_MAGNIFYING_GLASS "\xee\xac\x9c"
#define ICON_TB_MINUS "\xee\xab\xb2"
#define ICON_TB_PEN "\xee\xac\x84"
#define ICON_TB_PLUS "\xee\xac\x8b"
#define ICON_TB_ROBOT "\xef\x80\x8b"
#define ICON_TB_ROTATE_LEFT "\xee\xac\x96"
#define ICON_TB_ROTATE_RIGHT "\xee\xac\x95"
#define ICON_TB_SERVER "\xee\xac\x9f"
#define ICON_TB_TAG "\xee\xbe\x86"
#define ICON_TB_TERMINAL "\xee\xaf\xaf"
#define ICON_TB_TRASH_CAN "\xee\xad\x81"
#define ICON_TB_TREE "\xee\xbc\x81"
#define ICON_TB_TRIANGLE_EXCLAMATION "\xee\xa8\x86"
#define ICON_TB_UPLOAD "\xee\xad\x87"
#define ICON_TB_USER "\xee\xad\x8d"
#define ICON_TB_WINDOW_MAXIMIZE "\xee\xab\xaa"
#define ICON_TB_XMARK "\xee\xad\x95"