Compare commits

...

3 Commits

Author SHA1 Message Date
81a5c25071 Fix proportional font rendering with pixel-based horizontal scroll
The GUI renderer had two competing horizontal scroll systems: a
character-based one (coloffs × space_w) and a pixel-based one. For
proportional fonts the character-based system used "M" width to
calculate viewport columns, triggering premature scrolling at ~50%
of the actual display width.

Switch the GUI renderer to purely pixel-based horizontal scrolling:
- Remove coloffs↔ImGui scroll_x bidirectional sync
- Measure rx_to_px from column 0 (absolute) instead of from coloffs
- Draw full expanded lines; let ImGui clip via its scroll viewport
- Report content width via SetCursorPosX+Dummy for the scrollbar
- Use average character width for cols estimate (not "M" width)

The terminal renderer continues using coloffs correctly—no changes
needed there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:53 -07:00
5667a6d7bd Fix Linux build: default static linking off, add Clang link flag
KTE_STATIC_LINK defaulted to ON, which fails on systems where ncurses
is only available as a shared library. The Nix build already passed
-DKTE_STATIC_LINK=OFF explicitly; this makes the default match.

Also add add_link_options("-stdlib=libc++") for Clang builds — without
it, compilation uses libc++ but the linker defaults to libstdc++,
causing undefined symbol errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:26 -07:00
99c4bb2066 Fix font atlas crash and make Nix build first-class
Defer edit-mode font switch to next frame via RequestLoadFont() to
avoid modifying the locked ImFontAtlas between NewFrame() and Render().

Rework Nix packaging: split nativeBuildInputs/buildInputs correctly,
add devShells for nix develop, desktop file for kge, per-variant pname
and meta.mainProgram, and an overlay for NixOS configs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:26 -07:00
5 changed files with 119 additions and 76 deletions

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.10.1") set(KTE_VERSION "1.11.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -14,7 +14,7 @@ set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI") set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF) option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF) option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
option(KTE_STATIC_LINK "Enable static linking on Linux" ON) option(KTE_STATIC_LINK "Enable static linking on Linux" OFF)
# Optionally enable AddressSanitizer (ASan) # Optionally enable AddressSanitizer (ASan)
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF) option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
@@ -51,6 +51,7 @@ else ()
) )
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
add_compile_options("-stdlib=libc++") add_compile_options("-stdlib=libc++")
add_link_options("-stdlib=libc++")
else () else ()
# nothing special for gcc at the moment # nothing special for gcc at the moment
endif () endif ()

View File

@@ -76,7 +76,9 @@ static void
update_editor_dimensions(Editor &ed, float disp_w, float disp_h) update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
{ {
float row_h = ImGui::GetTextLineHeightWithSpacing(); float row_h = ImGui::GetTextLineHeightWithSpacing();
float ch_w = ImGui::CalcTextSize("M").x; // Use average character width rather than "M" (the widest character)
// so that column count is reasonable for proportional fonts too.
float ch_w = ImGui::CalcTextSize("abcdefghijklmnopqrstuvwxyz").x / 26.0f;
if (row_h <= 0.0f) if (row_h <= 0.0f)
row_h = 16.0f; row_h = 16.0f;
if (ch_w <= 0.0f) if (ch_w <= 0.0f)
@@ -565,7 +567,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
running = false; running = false;
} }
// Switch font based on current buffer's edit mode // Switch font based on current buffer's edit mode (deferred to next frame)
{ {
Buffer *cur = wed.CurrentBuffer(); Buffer *cur = wed.CurrentBuffer();
if (cur) { if (cur) {

View File

@@ -85,11 +85,9 @@ ImGuiRenderer::Draw(Editor &ed)
float target_y = static_cast<float>(buf_rowoffs) * row_h; float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y)); ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
} }
if (prev_buf_coloffs_ >= 0 && buf_coloffs != prev_buf_coloffs_) { // Horizontal scroll is handled purely in pixel space (see
float target_x = static_cast<float>(buf_coloffs) * space_w; // cursor-visibility block after the line loop) so we don't
float target_y = static_cast<float>(buf_rowoffs) * row_h; // convert the character-based coloffs to an ImGui scroll here.
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
}
// Reserve space for status bar at bottom. // Reserve space for status bar at bottom.
// We calculate a height that is an exact multiple of the line height // We calculate a height that is an exact multiple of the line height
@@ -114,26 +112,21 @@ ImGuiRenderer::Draw(Editor &ed)
bool forced_scroll = false; bool forced_scroll = false;
{ {
const long scroll_top = static_cast<long>(scroll_y / row_h); const long scroll_top = static_cast<long>(scroll_y / row_h);
const long scroll_left = static_cast<long>(scroll_x / space_w);
// Check if rowoffs was programmatically changed this frame // Check if rowoffs was programmatically changed this frame
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) { if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
forced_scroll = true; forced_scroll = true;
} }
// If user scrolled (not programmatic), update buffer offsets accordingly // If user scrolled vertically (not programmatic), update buffer row offset
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) { if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)), mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
mbuf->Coloffs()); mbuf->Coloffs());
} }
} }
if (prev_scroll_x_ >= 0.0f && scroll_x != prev_scroll_x_ && !forced_scroll) { // Horizontal scroll is pixel-based and managed by the cursor
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { // visibility block below; we don't sync it back to coloffs.
mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left)));
}
}
// Update trackers for next frame // Update trackers for next frame
prev_scroll_y_ = scroll_y; prev_scroll_y_ = scroll_y;
@@ -141,8 +134,8 @@ ImGuiRenderer::Draw(Editor &ed)
} }
prev_buf_rowoffs_ = buf_rowoffs; prev_buf_rowoffs_ = buf_rowoffs;
prev_buf_coloffs_ = buf_coloffs; prev_buf_coloffs_ = buf_coloffs;
// Cache current horizontal offset in rendered columns for click handling // Track the widest line in pixels for ImGui content width
const std::size_t coloffs_now = buf->Coloffs(); float max_line_width_px = 0.0f;
// Mark selection state (mark -> cursor), in source coordinates // Mark selection state (mark -> cursor), in source coordinates
bool sel_active = false; bool sel_active = false;
@@ -286,17 +279,14 @@ ImGuiRenderer::Draw(Editor &ed)
} }
} }
// Helper: convert a rendered column position to pixel x offset // Helper: convert a rendered column position to an absolute
// relative to the visible line start, using actual text measurement // pixel x offset from the start of the line. ImGui's scroll
// so proportional fonts render correctly. // handles viewport clipping so we measure from column 0.
auto rx_to_px = [&](std::size_t rx_col) -> float { auto rx_to_px = [&](std::size_t rx_col) -> float {
if (rx_col <= coloffs_now)
return 0.0f;
std::size_t start = coloffs_now;
std::size_t end = std::min(expanded.size(), rx_col); std::size_t end = std::min(expanded.size(), rx_col);
if (start >= expanded.size() || end <= start) if (end == 0)
return 0.0f; return 0.0f;
return ImGui::CalcTextSize(expanded.c_str() + start, return ImGui::CalcTextSize(expanded.c_str(),
expanded.c_str() + end).x; expanded.c_str() + end).x;
}; };
@@ -351,9 +341,6 @@ ImGuiRenderer::Draw(Editor &ed)
std::size_t sx = rg.first, ex = rg.second; std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_start = src_to_rx(sx); std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex); std::size_t rx_end = src_to_rx(ex);
// Apply horizontal scroll offset
if (rx_end <= coloffs_now)
continue; // fully left of view
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y); ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end), ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
line_pos.y + line_h); line_pos.y + line_h);
@@ -392,7 +379,6 @@ ImGuiRenderer::Draw(Editor &ed)
if (line_has) { if (line_has) {
std::size_t rx_start = src_to_rx(sx); std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex); std::size_t rx_end = src_to_rx(ex);
if (rx_end > coloffs_now) {
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
line_pos.y); line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end), ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
@@ -401,7 +387,6 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
} }
}
if (vsel_active && i >= vsel_sy && i <= vsel_ey) { if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot. // Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
const std::size_t spot_sx = std::min(buf->Curx(), line.size()); const std::size_t spot_sx = std::min(buf->Curx(), line.size());
@@ -413,7 +398,6 @@ ImGuiRenderer::Draw(Editor &ed)
// EOL spot: draw a 1-cell highlight just past the last character. // EOL spot: draw a 1-cell highlight just past the last character.
rx_end = rx_start + 1; rx_end = rx_start + 1;
} }
if (rx_end > coloffs_now) {
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
line_pos.y); line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end), ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
@@ -421,7 +405,6 @@ ImGuiRenderer::Draw(Editor &ed)
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg); ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
}
// Draw syntax-colored runs (text above background highlights) // Draw syntax-colored runs (text above background highlights)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine( kte::LineHighlight lh = buf->Highlighter()->GetLine(
@@ -464,16 +447,12 @@ ImGuiRenderer::Draw(Editor &ed)
for (const auto &sp: spans) { for (const auto &sp: spans) {
std::size_t rx_s = src_to_rx_full(sp.s); std::size_t rx_s = src_to_rx_full(sp.s);
std::size_t rx_e = src_to_rx_full(sp.e); std::size_t rx_e = src_to_rx_full(sp.e);
if (rx_e <= coloffs_now) std::size_t draw_start = rx_s;
continue; // fully left of viewport
// Clamp to visible portion and expanded length
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
if (draw_start >= expanded.size()) if (draw_start >= expanded.size())
continue; // fully right of expanded text continue;
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size()); std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
if (draw_end <= draw_start) if (draw_end <= draw_start)
continue; continue;
// Screen position via actual text measurement
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k)); ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start), ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
line_pos.y); line_pos.y);
@@ -484,17 +463,14 @@ ImGuiRenderer::Draw(Editor &ed)
// Use row_h (with spacing) to match click calculation and ensure consistent line positions. // Use row_h (with spacing) to match click calculation and ensure consistent line positions.
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h)); ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else { } else {
// No syntax: draw as one run, accounting for horizontal scroll offset // No syntax: draw the full line; ImGui scroll handles clipping.
if (coloffs_now < expanded.size()) { if (!expanded.empty()) {
ImVec2 p = ImVec2(line_pos.x, line_pos.y); ImVec2 p = ImVec2(line_pos.x, line_pos.y);
ImGui::GetWindowDrawList()->AddText( ImGui::GetWindowDrawList()->AddText(
p, ImGui::GetColorU32(ImGuiCol_Text), p, ImGui::GetColorU32(ImGuiCol_Text),
expanded.c_str() + coloffs_now); expanded.c_str());
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else {
// Line is fully scrolled out of view horizontally
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} }
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} }
// Draw a visible cursor indicator on the current line // Draw a visible cursor indicator on the current line
@@ -506,6 +482,18 @@ ImGuiRenderer::Draw(Editor &ed)
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
// Track widest line for content width reporting
if (!expanded.empty()) {
float line_w = ImGui::CalcTextSize(expanded.c_str()).x;
if (line_w > max_line_width_px)
max_line_width_px = line_w;
}
}
// Report content width to ImGui so horizontal scrollbar works correctly.
if (max_line_width_px > 0.0f) {
ImGui::SetCursorPosX(max_line_width_px);
ImGui::Dummy(ImVec2(0, 0));
} }
// Synchronize cursor and scrolling after rendering all lines so content size is known. // Synchronize cursor and scrolling after rendering all lines so content size is known.
{ {

View File

@@ -1,6 +1,5 @@
{ {
pkgs ? import <nixpkgs> {}, lib,
lib ? pkgs.lib,
stdenv, stdenv,
cmake, cmake,
ncurses, ncurses,
@@ -10,9 +9,10 @@
kdePackages, kdePackages,
qt6Packages ? kdePackages.qt6Packages, qt6Packages ? kdePackages.qt6Packages,
installShellFiles, installShellFiles,
copyDesktopItems,
makeDesktopItem,
graphical ? false, graphical ? false,
graphical-qt ? false, graphical-qt ? false,
...
}: }:
let let
cmakeContent = builtins.readFile ./CMakeLists.txt; cmakeContent = builtins.readFile ./CMakeLists.txt;
@@ -23,25 +23,29 @@ let
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine); version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
in in
stdenv.mkDerivation { stdenv.mkDerivation {
pname = "kte"; pname = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
inherit version; inherit version;
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
nativeBuildInputs = [ nativeBuildInputs = [
cmake cmake
ncurses
installShellFiles installShellFiles
] ] ++ lib.optionals graphical [
++ lib.optionals graphical [ copyDesktopItems
] ++ lib.optionals graphical-qt [
qt6Packages.wrapQtAppsHook
];
buildInputs = [
ncurses
] ++ lib.optionals graphical [
SDL2 SDL2
libGL libGL
xorg.libX11 xorg.libX11
] ] ++ lib.optionals graphical-qt [
++ lib.optionals graphical-qt [
kdePackages.qt6ct kdePackages.qt6ct
qt6Packages.qtbase qt6Packages.qtbase
qt6Packages.wrapQtAppsHook
]; ];
cmakeFlags = [ cmakeFlags = [
@@ -51,6 +55,30 @@ stdenv.mkDerivation {
"-DKTE_STATIC_LINK=OFF" "-DKTE_STATIC_LINK=OFF"
]; ];
desktopItems = lib.optionals graphical [
(makeDesktopItem {
name = "kge";
desktopName = "kge";
genericName = "Text Editor";
comment = "kyle's graphical text editor";
exec = if graphical-qt then "kge-qt %F" else "kge %F";
icon = "kge";
terminal = false;
categories = [ "Utility" "TextEditor" "Development" ];
mimeTypes = [
"text/plain"
"text/x-c"
"text/x-c++"
"text/x-python"
"text/x-go"
"text/x-rust"
"application/json"
"text/markdown"
"text/x-shellscript"
];
})
];
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
@@ -59,14 +87,11 @@ stdenv.mkDerivation {
installManPage ../docs/kte.1 installManPage ../docs/kte.1
${lib.optionalString graphical '' ${lib.optionalString graphical ''
mkdir -p $out/bin
${if graphical-qt then '' ${if graphical-qt then ''
cp kge $out/bin/kge-qt cp kge $out/bin/kge-qt
'' else '' '' else ''
cp kge $out/bin/kge cp kge $out/bin/kge
''} ''}
installManPage ../docs/kge.1 installManPage ../docs/kge.1
mkdir -p $out/share/icons/hicolor/256x256/apps mkdir -p $out/share/icons/hicolor/256x256/apps
@@ -75,4 +100,10 @@ stdenv.mkDerivation {
runHook postInstall runHook postInstall
''; '';
meta = {
description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
platforms = lib.platforms.unix;
mainProgram = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
};
} }

View File

@@ -3,8 +3,7 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = outputs = { self, nixpkgs, ... }:
inputs@{ self, nixpkgs, ... }:
let let
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed; eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
pkgsFor = system: import nixpkgs { inherit system; }; pkgsFor = system: import nixpkgs { inherit system; };
@@ -17,5 +16,27 @@
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; }; kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; }; qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
}); });
devShells = eachSystem (system:
let pkgs = pkgsFor system;
in {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.kge ];
packages = with pkgs; [ gdb valgrind ];
};
terminal = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.kte ];
};
qt = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.qt ];
packages = with pkgs; [ gdb valgrind ];
};
}
);
overlays.default = final: prev: {
kte = self.packages.${final.system}.kte;
kge = self.packages.${final.system}.kge;
};
}; };
} }