Add optional smooth scrolling across UI scrollable widgets

Introduce UIScrollController to provide frame-rate-independent wheel
interpolation, touch dragging, and momentum for code editors, terminals,
scroll views, and scrollable widgets while preserving the legacy instant
scrolling path.

Add a UISceneNode-level smooth-scrolling policy. Embedded scenes inherit
the root scene setting, new widgets snapshot it during construction, and
setSmoothScrollEnabled() can optionally apply changes to existing widgets
across the complete embedded-scene hierarchy.

Move native macOS scrolling and title-bar helpers into PlatformHelper,
enable native momentum from UIApplication, and explicitly enable it in
ecode. Preserve precise SDL2 and SDL3 wheel deltas for fractional momentum
events without changing normal integer wheel steps.

Replace the separate editor and terminal preferences in ecode and eterm
with one global smooth-scrolling setting, including legacy configuration
migration, live updates, and localized settings text.

Improve UITouchDraggableWidget momentum handling and add regression tests
for scrolling interpolation, pixel-aligned editor positions, scene
inheritance, widget overrides, and immediate policy application.
This commit is contained in:
Martín Lucas Golini
2026-09-13 20:27:37 -03:00
parent 42de99cf3b
commit 071bc5d697
46 changed files with 1250 additions and 305 deletions
+7 -1
View File
@@ -157,6 +157,10 @@ void AppConfig::load( const std::string& confPath, std::string& keybindingsPath,
ui.openProjectInNewWindow = ini.getValueB( "ui", "open_project_in_new_window", false );
ui.nativeFileDialogs = ini.getValueB( "ui", "native_file_dialogs", false );
ui.imagesQuickPreview = ini.getValueB( "ui", "images_quick_preview", false );
ui.smoothScroll = ini.getValueB(
"ui", "smooth_scroll",
ini.getValueB( "editor", "smooth_scroll",
ini.getValueB( "terminal", "smooth_scroll", false ) ) );
ui.panelPosition = panelPositionFromString( ini.getValue( "ui", "panel_position", "left" ) );
ui.sansSerifFont = ini.getValue( "ui", "serif_font", "fonts/NotoSans-Regular.ttf" );
ui.monospaceFont = ini.getValue( "ui", "monospace_font", "fonts/DejaVuSansMono.ttf" );
@@ -374,6 +378,9 @@ void AppConfig::save( const std::vector<std::string>& recentFiles,
ini.setValueB( "ui", "open_project_in_new_window", ui.openProjectInNewWindow );
ini.setValueB( "ui", "native_file_dialogs", ui.nativeFileDialogs );
ini.setValueB( "ui", "images_quick_preview", ui.imagesQuickPreview );
ini.setValueB( "ui", "smooth_scroll", ui.smoothScroll );
ini.deleteValue( "editor", "smooth_scroll" );
ini.deleteValue( "terminal", "smooth_scroll" );
ini.setValue( "ui", "panel_position", panelPositionToString( ui.panelPosition ) );
ini.setValue( "ui", "serif_font", ui.sansSerifFont );
ini.setValue( "ui", "monospace_font", ui.monospaceFont );
@@ -479,7 +486,6 @@ void AppConfig::save( const std::vector<std::string>& recentFiles,
term.scrollBarMode == ScrollBarMode::Auto
? "auto"sv
: ( term.scrollBarMode == ScrollBarMode::AlwaysOn ? "on"sv : "off" ) );
ini.setValueB( "window", "vsync", context.VSync );
ini.setValue( "window", "glversion",
Renderer::graphicsLibraryVersionToString( context.Version ) );
+1
View File
@@ -72,6 +72,7 @@ struct UIConfig {
bool nativeFileDialogs{ false };
bool imagesQuickPreview{ false };
bool editorFontInInputFields{ true };
bool smoothScroll{ false };
PanelPosition panelPosition{ PanelPosition::Left };
std::string sansSerifFont;
std::string monospaceFont;
+75 -68
View File
@@ -28,6 +28,7 @@
#include <eepp/ui/tools/uiaudioplayer.hpp>
#include <eepp/ui/tools/uidiffview.hpp>
#include <eepp/ui/tools/uiimageviewer.hpp>
#include <eepp/window/platformhelper.hpp>
#include <filesystem>
#include <iostream>
@@ -55,10 +56,6 @@ using namespace std::literals;
namespace fs = std::filesystem;
using json = nlohmann::json;
#if EE_PLATFORM == EE_PLATFORM_MACOS
#include "macos/macos.hpp"
#endif
namespace ecode {
Clock globalClock;
@@ -2159,77 +2156,85 @@ static Uint32 DefaultSwitchToStatusPanelModifier = KeyMod::getDefaultSecondaryMo
KeyBindings::ShortcutMap App::getLocalKeybindings() {
return {
{ { KEY_PRINTSCREEN, KEYMOD_NONE }, "take-screenshot" },
{ { KEY_RETURN, KeyMod::getDefaultSecondaryModifier() | KeyMod::getDefaultModifier() },
"fullscreen-toggle" },
{ { KEY_F3, KEYMOD_NONE }, "repeat-find" },
{ { KEY_F3, KEYMOD_SHIFT }, "find-prev" },
{ { KEY_F12, KEYMOD_NONE }, "console-toggle" },
{ { KEY_F, KeyMod::getDefaultModifier() }, "find-replace" },
{ { KEY_Q, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "close-app" },
{ { KEY_O, KeyMod::getDefaultModifier() }, "open-file" },
{ { KEY_W, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "download-file-web" },
{ { KEY_O, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-folder" },
{ { KEY_F11, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "debug-widget-tree-view" },
{ { KEY_K, KeyMod::getDefaultModifier() }, "open-locatebar" },
{ { KEY_P, KeyMod::getDefaultModifier() }, "open-command-palette" },
{ { KEY_COMMA, KeyMod::getDefaultModifier() }, "open-settings" },
{ { KEY_COMMA, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-project-settings" },
{ { KEY_F, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-global-search" },
{ { KEY_L, KeyMod::getDefaultModifier() }, "go-to-line" },
{ { KEY_RETURN, KeyMod::getDefaultSecondaryModifier() | KeyMod::getDefaultModifier() },
"fullscreen-toggle" },
{ { KEY_F3, KEYMOD_NONE }, "repeat-find" }, { { KEY_F3, KEYMOD_SHIFT }, "find-prev" },
{ { KEY_F12, KEYMOD_NONE }, "console-toggle" },
{ { KEY_F, KeyMod::getDefaultModifier() }, "find-replace" },
{ { KEY_Q, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "close-app" },
{ { KEY_O, KeyMod::getDefaultModifier() }, "open-file" },
{ { KEY_W, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "download-file-web" },
{ { KEY_O, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-folder" },
{ { KEY_F11, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "debug-widget-tree-view" },
{ { KEY_K, KeyMod::getDefaultModifier() }, "open-locatebar" },
{ { KEY_P, KeyMod::getDefaultModifier() }, "open-command-palette" },
{ { KEY_COMMA, KeyMod::getDefaultModifier() }, "open-settings" },
{ { KEY_COMMA, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-project-settings" },
{ { KEY_F, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-global-search" },
{ { KEY_L, KeyMod::getDefaultModifier() }, "go-to-line" },
#if EE_PLATFORM == EE_PLATFORM_MACOS
{ { KEY_M, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "menu-toggle" },
{ { KEY_M, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "menu-toggle" },
#else
{ { KEY_M, KeyMod::getDefaultModifier() }, "menu-toggle" },
{ { KEY_M, KeyMod::getDefaultModifier() }, "menu-toggle" },
#endif
{ { KEY_S, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "save-all" },
{ { KEY_F9, KeyMod::getDefaultSecondaryModifier() }, "switch-side-panel" },
{ { KEY_J,
KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },
"terminal-split-left" },
{ { KEY_L,
KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },
"terminal-split-right" },
{ { KEY_I,
KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },
"terminal-split-top" },
{ { KEY_K,
KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },
"terminal-split-bottom" },
{ { KEY_S,
KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },
"terminal-split-swap" },
{ { KEY_T,
KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },
"reopen-closed-tab" },
{ { KEY_1, DefaultSwitchToStatusPanelModifier }, "toggle-status-locate-bar" },
{ { KEY_2, DefaultSwitchToStatusPanelModifier }, "toggle-status-global-search-bar" },
{ { KEY_3, DefaultSwitchToStatusPanelModifier }, "toggle-status-terminal" },
{ { KEY_4, DefaultSwitchToStatusPanelModifier }, "toggle-status-build-output" },
{ { KEY_5, DefaultSwitchToStatusPanelModifier }, "toggle-status-app-output" },
{ { KEY_B, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "project-build-start-cancel" },
{ { KEY_C, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "project-build-cancel" },
{ { KEY_R, KeyMod::getDefaultModifier() }, "project-build-and-run" },
{ { KEY_O, KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT }, "show-open-documents" },
{ { KEY_K, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-workspace-symbol-search" },
{ { KEY_P, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "open-document-symbol-search" },
{ { KEY_N, KEYMOD_SHIFT | KeyMod::getDefaultSecondaryModifier() }, "create-new-window" },
{ { KEY_S, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "save-all" },
{ { KEY_F9, KeyMod::getDefaultSecondaryModifier() }, "switch-side-panel" },
{ { KEY_J, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() |
KEYMOD_SHIFT },
"terminal-split-left" },
{ { KEY_L, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() |
KEYMOD_SHIFT },
"terminal-split-right" },
{ { KEY_I, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() |
KEYMOD_SHIFT },
"terminal-split-top" },
{ { KEY_K, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() |
KEYMOD_SHIFT },
"terminal-split-bottom" },
{ { KEY_S, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() |
KEYMOD_SHIFT },
"terminal-split-swap" },
{ { KEY_T, KeyMod::getDefaultModifier() | KeyMod::getDefaultSecondaryModifier() |
KEYMOD_SHIFT },
"reopen-closed-tab" },
{ { KEY_1, DefaultSwitchToStatusPanelModifier }, "toggle-status-locate-bar" },
{ { KEY_2, DefaultSwitchToStatusPanelModifier }, "toggle-status-global-search-bar" },
{ { KEY_3, DefaultSwitchToStatusPanelModifier }, "toggle-status-terminal" },
{ { KEY_4, DefaultSwitchToStatusPanelModifier }, "toggle-status-build-output" },
{ { KEY_5, DefaultSwitchToStatusPanelModifier }, "toggle-status-app-output" },
{ { KEY_B, KeyMod::getDefaultModifier() | KEYMOD_SHIFT },
"project-build-start-cancel" },
{ { KEY_C, KeyMod::getDefaultModifier() | KEYMOD_SHIFT }, "project-build-cancel" },
{ { KEY_R, KeyMod::getDefaultModifier() }, "project-build-and-run" },
{ { KEY_O, KeyMod::getDefaultSecondaryModifier() | KEYMOD_SHIFT },
"show-open-documents" },
{ { KEY_K, KeyMod::getDefaultModifier() | KEYMOD_SHIFT },
"open-workspace-symbol-search" },
{ { KEY_P, KeyMod::getDefaultModifier() | KEYMOD_SHIFT },
"open-document-symbol-search" },
{ { KEY_N, KEYMOD_SHIFT | KeyMod::getDefaultSecondaryModifier() },
"create-new-window" },
};
}
// Old keybindings will be rebinded to the new keybindings when they are still set to the old
// keybindind
std::map<std::string, std::string> App::getMigrateKeybindings() {
return { { "fullscreen-toggle", "alt+return" }, { "switch-to-tab-1", "alt+1" },
{ "switch-to-tab-2", "alt+2" }, { "switch-to-tab-3", "alt+3" },
{ "switch-to-tab-4", "alt+4" }, { "switch-to-tab-5", "alt+5" },
{ "switch-to-tab-6", "alt+6" }, { "switch-to-tab-7", "alt+7" },
{ "switch-to-tab-8", "alt+8" }, { "switch-to-tab-9", "alt+9" },
{ "switch-to-last-tab", "alt+0" },
return {
{ "fullscreen-toggle", "alt+return" }, { "switch-to-tab-1", "alt+1" },
{ "switch-to-tab-2", "alt+2" }, { "switch-to-tab-3", "alt+3" },
{ "switch-to-tab-4", "alt+4" }, { "switch-to-tab-5", "alt+5" },
{ "switch-to-tab-6", "alt+6" }, { "switch-to-tab-7", "alt+7" },
{ "switch-to-tab-8", "alt+8" }, { "switch-to-tab-9", "alt+9" },
{ "switch-to-last-tab", "alt+0" },
#if EE_PLATFORM == EE_PLATFORM_MACOS
{ "menu-toggle", "mod+shift+m" },
{ "menu-toggle", "mod+shift+m" },
#endif
{ "lock-toggle", "mod+shift+l" }, { "debug-widget-tree-view", "f11" },
{ "project-build-and-run", "f5" }, { "project-build-start", "mod+shift+b" } };
{ "lock-toggle", "mod+shift+l" }, { "debug-widget-tree-view", "f11" },
{ "project-build-and-run", "f5" }, {
"project-build-start", "mod+shift+b"
}
};
}
std::vector<std::string> App::getUnlockedCommands() {
@@ -4518,8 +4523,8 @@ void App::tintTitleBar() {
->getVariableByName( "--back" );
if ( !backVar.isEmpty() ) {
auto backColor( Color::fromString( backVar.getValue() ) );
macOS_changeTitleBarColor( mWindow->getWindowHandler(), backColor.r / 255.f,
backColor.g / 255.f, backColor.b / 255.f );
Engine::instance()->getPlatformHelper()->setWindowTitleBarColor(
mWindow->getWindowHandler(), backColor.r, backColor.g, backColor.b );
}
}
#endif
@@ -4748,8 +4753,9 @@ void App::init( InitParameters& params ) {
EE_PLATFORM == EE_PLATFORM_BSD
#if EE_PLATFORM == EE_PLATFORM_MACOS
macOS_enableScrollMomentum();
macOS_removeTitleBarSeparator( mWindow->getWindowHandler() );
engine->getPlatformHelper()->setNativeScrollMomentumEnabled( true );
engine->getPlatformHelper()->setWindowTitleBarSeparatorVisible( mWindow->getWindowHandler(),
false );
#endif
mThreadPool->run( [this]() {
@@ -4954,6 +4960,7 @@ void App::init( InitParameters& params ) {
mUISceneNode = UISceneNode::New();
mLifetime.setDispatcher( mUISceneNode );
mUISceneNode->setThreadPool( mThreadPool );
mUISceneNode->setSmoothScrollEnabled( mConfig.ui.smoothScroll );
mUIColorScheme = mConfig.ui.colorScheme;
if ( params.language.empty() )
-18
View File
@@ -1,18 +0,0 @@
#ifndef ECODE_MACOS_MACOS
#define ECODE_MACOS_MACOS
#ifdef __cplusplus
extern "C" {
#endif
void macOS_enableScrollMomentum();
void macOS_removeTitleBarSeparator( void* nsWindow );
void macOS_changeTitleBarColor( void* window, double red, double green, double blue );
#ifdef __cplusplus
}
#endif
#endif
-19
View File
@@ -1,19 +0,0 @@
#import <AppKit/AppKit.h>
#include <Cocoa/Cocoa.h>
#include "macos.hpp"
void macOS_enableScrollMomentum() {
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"AppleMomentumScrollSupported"];
}
void macOS_removeTitleBarSeparator( void* window ) {
NSWindow* nsWindow = window;
[nsWindow setTitlebarSeparatorStyle:NSTitlebarSeparatorStyleNone];
}
void macOS_changeTitleBarColor( void* window, double red, double green, double blue ) {
NSWindow* nsWindow = window;
nsWindow.titlebarAppearsTransparent = YES;
nsWindow.backgroundColor = [NSColor colorWithRed:red green:green blue:blue alpha:1.];
}
+11 -3
View File
@@ -77,8 +77,9 @@ void SettingsPanel::create( Scope scope ) {
addProjectSettings( state );
}
state.panel->build();
state.connections += state.window->connect(
Event::OnWindowReady, [&state]( const Event* ) { state.panel->focusSearch(); } );
state.connections += state.window->connect( Event::OnWindowReady, [&state]( const Event* ) {
state.panel->runOnMainThread( [&state] { state.panel->focusSearch(); } );
} );
state.connections +=
state.window->connect( Event::OnWindowClose, [this, &state, scope]( const Event* ) {
for ( const auto& document : state.documents ) {
@@ -203,6 +204,13 @@ void SettingsPanel::addUserSettings( PanelState& panel ) {
mApp->i18n( "quick_preview_images_tooltip",
"Preview images without opening a permanent editor tab." ) },
&mApp->getConfig().ui.imagesQuickPreview );
addBool(
panel,
{ "smoothScroll", "general.behavior", mApp->i18n( "smooth_scroll", "Smooth Scrolling" ),
mApp->i18n( "smooth_scroll_desc",
"Animate scrolling from mouse wheels and trackpads." ) },
&mApp->getConfig().ui.smoothScroll,
[this]( bool value ) { mApp->getUISceneNode()->setSmoothScrollEnabled( value, true ); } );
addCategory( panel, "editor.appearance", mApp->i18n( "editor", "Editor" ),
mApp->i18n( "appearance", "Appearance" ) );
@@ -1157,7 +1165,7 @@ void SettingsPanel::addUserSettings( PanelState& panel ) {
[this, monitorRefreshRate, unlimitedFrameRate] {
const auto value = mApp->getConfig().context.FrameRateLimit;
return value == ContextSettings::FrameRateLimitScreenRefreshRate ? monitorRefreshRate
: value == 0 ? unlimitedFrameRate
: value == 0 ? unlimitedFrameRate
: String( String::toString( value ) );
},
[this, monitorRefreshRate, unlimitedFrameRate]( const String& selection ) {
+4
View File
@@ -33,6 +33,8 @@ void AppConfig::load() {
terminal.closeOnExit = mIni.getValueB( "terminal", "close_on_exit", terminal.closeOnExit );
terminal.initialTabs = mIni.getValueU( "terminal", "initial_tabs", terminal.initialTabs );
terminal.exclusiveMode = mIni.getValueB( "terminal", "exclusive_mode", terminal.exclusiveMode );
ui.smoothScroll = mIni.getValueB(
"ui", "smooth_scroll", mIni.getValueB( "terminal", "smooth_scroll", ui.smoothScroll ) );
const auto newTerminalBehavior =
mIni.getValue( "terminal", "new_terminal_behavior", "current" );
terminal.newTerminalBehavior =
@@ -104,6 +106,8 @@ bool AppConfig::savePreferences() {
mIni.setValueB( "terminal", "close_on_exit", terminal.closeOnExit );
mIni.setValueU( "terminal", "initial_tabs", terminal.initialTabs );
mIni.setValueB( "terminal", "exclusive_mode", terminal.exclusiveMode );
mIni.setValueB( "ui", "smooth_scroll", ui.smoothScroll );
mIni.deleteValue( "terminal", "smooth_scroll" );
mIni.setValue( "terminal", "new_terminal_behavior",
terminal.newTerminalBehavior == NewTerminalBehavior::VerticalSplit ? "vertical"
: terminal.newTerminalBehavior == NewTerminalBehavior::HorizontalSplit
+5
View File
@@ -38,6 +38,10 @@ struct TerminalConfig {
ScrollBarMode scrollBarMode{ ScrollBarMode::Auto };
};
struct UIConfig {
bool smoothScroll{ false };
};
struct FontConfig {
std::string path;
std::string fallbackPath;
@@ -76,6 +80,7 @@ struct AppConfig {
FontConfig font;
WindowConfig window;
ThemeConfig theme;
UIConfig ui;
WindowStateConfig windowState;
explicit AppConfig( std::string configPath );
+1
View File
@@ -724,6 +724,7 @@ int App::run( int argc, char* argv[] ) {
scene = app.getUI();
if ( !appWindow || !appWindow->isOpen() || !scene )
return EXIT_FAILURE;
scene->setSmoothScrollEnabled( config->ui.smoothScroll );
settingsActions = std::make_unique<SettingsActions>( this );
keybindingsPath = config->getConfigPath() + "keybindings.cfg";
loadKeybindings();
+8
View File
@@ -234,6 +234,14 @@ UIWindow* SettingsPanel::create( App& app ) {
[value]( UITerminal* terminal ) { terminal->setExclusiveMode( value ); } );
app.savePreferences();
} );
panel->addBool( { "smoothScroll", "terminal.behavior",
app.i18n( "smooth_scroll", "Smooth Scrolling" ),
app.i18n( "smooth_scroll_desc",
"Animate scrolling from mouse wheels and trackpads." ) },
&app.config->ui.smoothScroll, [&app]( bool value ) {
app.scene->setSmoothScrollEnabled( value, true );
app.savePreferences();
} );
panel->addBool(
{ "closeTerminalTabOnExit", "terminal.behavior",
app.i18n( "close_terminal_tab_on_exit", "Close Terminal Tab on Exit" ),