diff --git a/.agents/rules/build-project.md b/.agents/rules/build-project.md index f2d8f63f2..0f99709ce 100644 --- a/.agents/rules/build-project.md +++ b/.agents/rules/build-project.md @@ -134,6 +134,12 @@ native runtime on a usable desktop. Use the native/Xvfb paths below for examples required check cannot run with the headless runtime. Verify the renderer before using an offscreen run to assess hardware rendering or performance. +When the headless driver is unavailable, use `EEPP_RUNTIME=native EEPP_WINDOW_HIDDEN=1` for ordinary +application/example launches and inspector automation. This preserves native rendering while +forcing primary and secondary eepp windows hidden, including explicit `show()`/restore requests; +it avoids temporary source edits to add `WindowStyle::Hidden`. A usable native display is still +required. See `docs/articles/ui_inspector.md` for the environment flag's behavior. + Xvfb does not support the multisampled OpenGL contexts requested by some eepp examples. In particular, `src/examples/ui_html/ui_html.cpp` normally requests 4x MSAA. Launching that binary through `xvfb-run` or `projects/scripts/xvfb-run-eepp` can therefore fail immediately with diff --git a/.agents/rules/project-introduction.md b/.agents/rules/project-introduction.md index 9d36b2504..12b5062c2 100644 --- a/.agents/rules/project-introduction.md +++ b/.agents/rules/project-introduction.md @@ -40,8 +40,11 @@ database, launching applications) may use local platform APIs. See `docs/article AI agents should prefer `EEPP_RUNTIME=headless` when launching ordinary applications or examples and when automating their UI with the inspector. This avoids opening desktop windows or interrupting -the human's keyboard input and focus. Coordinate interactive checks that require visible native -windows with the user before driving them. +the human's keyboard input and focus. When headless rendering is unavailable (notably on macOS), +or native rendering needs verification, use `EEPP_RUNTIME=native EEPP_WINDOW_HIDDEN=1` instead of +modifying application sources to add `WindowStyle::Hidden`. This also keeps secondary native windows +hidden, even if the application calls `show()` or restores them. Coordinate interactive checks that +require visible native windows with the user before driving them. Unit tests and benchmarks are different: run their release binaries with the native runtime on a usable desktop. The unit-test build defines `EE_UNIT_TESTS`, which adds `WindowStyle::Hidden` to diff --git a/docs/articles/ui_inspector.md b/docs/articles/ui_inspector.md index 5d4282315..b3f281bae 100644 --- a/docs/articles/ui_inspector.md +++ b/docs/articles/ui_inspector.md @@ -39,6 +39,26 @@ Copy the `port` and generated `token` from that startup line into the client env The default port is selected by the OS at launch, so the client cannot infer it in advance. If you set `EEPP_INSPECTOR_TOKEN` yourself, use that value for the client instead. +## Hidden native windows + +Set `EEPP_WINDOW_HIDDEN=1` to inspect an application without opening desktop windows: + +```sh +EEPP_RUNTIME=native EEPP_WINDOW_HIDDEN=1 EEPP_INSPECTOR=1 ./bin/eefm +``` + +The flag forces every eepp window created through `Engine::createWindow()` hidden, including +secondary application windows and native dialog hosts. Native `show()`, `raise()`, `restore()`, +`maximize()`, and fullscreen requests cannot reveal these windows while the flag is enabled. +Widget visibility, rendering, screenshots, and inspector input remain available. Set the flag before +launch; only the exact value `1` enables it. Unset, empty, `0`, and all other values disable it. + +This flag preserves the selected runtime and renderer. With `EEPP_RUNTIME=native`, it still needs a +usable native display, but keeps the windows hidden. Use this when the headless SDL driver is +unavailable (for example, on macOS) or when checking native rendering without interrupting desktop +focus. `EEPP_RUNTIME=headless` instead selects an offscreen video driver. The visibility flag does +not suppress operating-system dialogs opened outside eepp's window API. + ## Python client `projects/scripts/eepp-inspect.py` requires Python 3 and only the standard library. It reads `EEPP_INSPECTOR_HOST`, `EEPP_INSPECTOR_PORT`, and `EEPP_INSPECTOR_TOKEN`, or the corresponding `--host`, `--port`, and `--token` options. Use `--pretty` for indented JSON. Default stdout is compact JSON only; diagnostics go to stderr. Ordinary commands connect, authenticate, print one response, and disconnect. diff --git a/include/eepp/window/runtime.hpp b/include/eepp/window/runtime.hpp index 55295a5f7..b7b4e2a29 100644 --- a/include/eepp/window/runtime.hpp +++ b/include/eepp/window/runtime.hpp @@ -12,7 +12,7 @@ enum class RuntimeMode : Uint8 { Terminal //!< Offscreen rendering presented through a terminal transport. }; -/** Process-level runtime selection, resolved once from EEPP_RUNTIME. */ +/** Process-level runtime selection and window visibility overrides. */ class EE_API Runtime { public: /** @return The process runtime mode resolved from EEPP_RUNTIME on first use. */ @@ -21,6 +21,10 @@ class EE_API Runtime { /** @return True when the runtime requires an offscreen video driver and logical framebuffer. */ static bool isOffscreen(); + /** @return True when EEPP_WINDOW_HIDDEN is exactly "1". This does not change the runtime + * or renderer. Set it before creating windows to suppress native window presentation. */ + static bool forceHiddenWindows(); + /** @return A stable lowercase name for the resolved runtime mode. */ static const char* modeName(); }; diff --git a/src/eepp/window/backend/SDL2/windowsdl2.cpp b/src/eepp/window/backend/SDL2/windowsdl2.cpp index fedf0fe45..8f5e9b192 100644 --- a/src/eepp/window/backend/SDL2/windowsdl2.cpp +++ b/src/eepp/window/backend/SDL2/windowsdl2.cpp @@ -524,6 +524,8 @@ void WindowSDL::setGLConfig() { } void WindowSDL::toggleFullscreen() { + if ( Runtime::forceHiddenWindows() ) + return; Log::info( "toggleFullscreen called: %s", isWindowed() ? "is windowed" : "is fullscreen" ); if ( isWindowed() ) { @@ -615,6 +617,8 @@ void WindowSDL::onWindowResize( Uint32 width, Uint32 height ) { } void WindowSDL::setSize( Uint32 width, Uint32 height, bool windowed ) { + if ( Runtime::forceHiddenWindows() ) + windowed = true; if ( ( !width || !height ) ) { width = mWindow.DesktopResolution.getWidth(); height = mWindow.DesktopResolution.getHeight(); @@ -817,7 +821,7 @@ void WindowSDL::minimize() { } void WindowSDL::maximize() { - if ( Runtime::isOffscreen() ) + if ( Runtime::isOffscreen() || Runtime::forceHiddenWindows() ) return; SDL_MaximizeWindow( mSDLWindow ); } @@ -835,10 +839,14 @@ void WindowSDL::hide() { } void WindowSDL::raise() { + if ( Runtime::forceHiddenWindows() ) + return; SDL_RaiseWindow( mSDLWindow ); } void WindowSDL::restore() { + if ( Runtime::forceHiddenWindows() ) + return; SDL_RestoreWindow( mSDLWindow ); } @@ -856,6 +864,8 @@ void WindowSDL::flash( WindowFlashOperation op ) { } void WindowSDL::show() { + if ( Runtime::forceHiddenWindows() ) + return; SDL_ShowWindow( mSDLWindow ); } diff --git a/src/eepp/window/backend/SDL3/windowsdl3.cpp b/src/eepp/window/backend/SDL3/windowsdl3.cpp index 0e3adf580..b6afba441 100644 --- a/src/eepp/window/backend/SDL3/windowsdl3.cpp +++ b/src/eepp/window/backend/SDL3/windowsdl3.cpp @@ -486,6 +486,8 @@ void WindowSDL::setGLConfig() { } void WindowSDL::toggleFullscreen() { + if ( Runtime::forceHiddenWindows() ) + return; Log::info( "toggleFullscreen called: %s", isWindowed() ? "is windowed" : "is fullscreen" ); if ( isWindowed() ) { @@ -579,6 +581,8 @@ void WindowSDL::onWindowResize( Uint32 width, Uint32 height ) { } void WindowSDL::setSize( Uint32 width, Uint32 height, bool windowed ) { + if ( Runtime::forceHiddenWindows() ) + windowed = true; if ( ( !width || !height ) ) { width = mWindow.DesktopResolution.getWidth(); height = mWindow.DesktopResolution.getHeight(); @@ -747,7 +751,7 @@ void WindowSDL::minimize() { } void WindowSDL::maximize() { - if ( Runtime::isOffscreen() ) + if ( Runtime::isOffscreen() || Runtime::forceHiddenWindows() ) return; SDL_MaximizeWindow( mSDLWindow ); } @@ -765,10 +769,14 @@ void WindowSDL::hide() { } void WindowSDL::raise() { + if ( Runtime::forceHiddenWindows() ) + return; SDL_RaiseWindow( mSDLWindow ); } void WindowSDL::restore() { + if ( Runtime::forceHiddenWindows() ) + return; SDL_RestoreWindow( mSDLWindow ); } @@ -784,6 +792,8 @@ void WindowSDL::flash( WindowFlashOperation op ) { } void WindowSDL::show() { + if ( Runtime::forceHiddenWindows() ) + return; SDL_ShowWindow( mSDLWindow ); } diff --git a/src/eepp/window/engine.cpp b/src/eepp/window/engine.cpp index 3f54c1444..717022c5f 100644 --- a/src/eepp/window/engine.cpp +++ b/src/eepp/window/engine.cpp @@ -13,11 +13,11 @@ #include #include #include +#include #include #include #include #include -#include #include #include #include @@ -299,6 +299,10 @@ EE::Window::Window* Engine::createDefaultWindow( const WindowSettings& Settings, } EE::Window::Window* Engine::createWindow( WindowSettings Settings, ContextSettings Context ) { + if ( Runtime::forceHiddenWindows() ) { + Settings.Style |= WindowStyle::Hidden; + Settings.Style &= ~( WindowStyle::Fullscreen | WindowStyle::UseDesktopResolution ); + } const bool firstWindow = mWindows.empty(); if ( Runtime::mode() == RuntimeMode::Terminal && !mWindows.empty() ) { Log::error( "Terminal runtime currently supports one top-level Window" ); diff --git a/src/eepp/window/runtime.cpp b/src/eepp/window/runtime.cpp index 09b503372..4813c6069 100644 --- a/src/eepp/window/runtime.cpp +++ b/src/eepp/window/runtime.cpp @@ -29,6 +29,11 @@ bool Runtime::isOffscreen() { return mode() != RuntimeMode::Native; } +bool Runtime::forceHiddenWindows() { + const char* value = std::getenv( "EEPP_WINDOW_HIDDEN" ); + return value && 0 == std::strcmp( value, "1" ); +} + const char* Runtime::modeName() { switch ( mode() ) { case RuntimeMode::Headless: diff --git a/src/tests/unit_tests/runtime_tests.cpp b/src/tests/unit_tests/runtime_tests.cpp new file mode 100644 index 000000000..b7823252f --- /dev/null +++ b/src/tests/unit_tests/runtime_tests.cpp @@ -0,0 +1,54 @@ +#include "utest.h" +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::Window; + +UTEST( Runtime, hiddenWindowOverrideRequiresOneAndPreservesRuntime ) { + const std::string previous = Sys::getEnv( "EEPP_WINDOW_HIDDEN" ); + ScopedOp environment( nullptr, + [&previous] { Sys::setEnv( "EEPP_WINDOW_HIDDEN", previous.c_str() ); } ); + const auto mode = Runtime::mode(); + for ( const char* value : { "", "0", "true", "false", "01", "1" } ) { + EXPECT_TRUE( Sys::setEnv( "EEPP_WINDOW_HIDDEN", value ) ); + EXPECT_EQ( Runtime::forceHiddenWindows(), value[0] == '1' && value[1] == '\0' ); + EXPECT_EQ( Runtime::mode(), mode ); + } +} + +UTEST( UIApplication, forcedHiddenWindowsIgnorePresentationRequests ) { + if ( Runtime::mode() == RuntimeMode::Terminal ) + return; + const std::string previous = Sys::getEnv( "EEPP_WINDOW_HIDDEN" ); + ScopedOp environment( [] { Sys::setEnv( "EEPP_WINDOW_HIDDEN", "1" ); }, + [&previous] { Sys::setEnv( "EEPP_WINDOW_HIDDEN", previous.c_str() ); } ); + WindowSettings settings{ 320, 240, "Forced Hidden Window Test" }; + // Do not inherit the unit harness's hidden default: the environment must force both windows + // hidden and suppress the requested fullscreen desktop mode before native creation. + settings.Style = WindowStyle::Titlebar | WindowStyle::Resize | WindowStyle::Fullscreen | + WindowStyle::UseDesktopResolution; + UIApplication app( settings ); + auto* secondary = app.createWindow( settings ); + ASSERT_TRUE( secondary != nullptr ); + for ( auto* window : { app.getWindow(), secondary->getWindow() } ) { + EXPECT_TRUE( window->getWindowInfo()->WindowConfig.Style & WindowStyle::Hidden ); + EXPECT_FALSE( window->getWindowInfo()->WindowConfig.Style & + ( WindowStyle::Fullscreen | WindowStyle::UseDesktopResolution ) ); + EXPECT_FALSE( window->isVisible() ); + EXPECT_TRUE( window->isWindowed() ); + window->show(); + window->raise(); + window->restore(); + window->maximize(); + window->toggleFullscreen(); + window->setSize( 360, 260, false ); + EXPECT_FALSE( window->isVisible() ); + EXPECT_TRUE( window->isWindowed() ); + } +} diff --git a/src/tools/eefm/app.cpp b/src/tools/eefm/app.cpp index e54ef87c8..ad6c3fe50 100644 --- a/src/tools/eefm/app.cpp +++ b/src/tools/eefm/app.cpp @@ -261,10 +261,21 @@ Tab Tab::close:hover, Tab:not(:selected) Tab::close:hover { foreground-tint: var(--tab-close-hover); } -/* Scrollbars stay out of the way until used. */ +/* The focused pane uses the theme's thumb color; the others stay muted. */ ScrollBar::vbutton, ScrollBar::hbutton { background-color: rgba( var(--font-hint), 0.45 ); } +.pane:focus-within ScrollBar::vbutton, +.pane:focus-within ScrollBar::hbutton { + background-color: var(--scrollbar-button); +} +/* Keep Breeze's hover color above the more specific focused-pane rule. */ +.pane:focus-within ScrollBar::vslider:hover ScrollBar::vbutton, +.pane:focus-within ScrollBar::vbutton:hover, +.pane:focus-within ScrollBar::hslider:hover ScrollBar::hbutton, +.pane:focus-within ScrollBar::hbutton:hover { + background-color: var(--primary); +} .transfer_text { margin-right: 6dp; } @@ -617,28 +628,6 @@ bool App::init( const std::vector& locations, bool forceSplit ) { } else if ( event->Type == InputEvent::EventsSent ) { if ( mFileOperations->hasDroppedFiles() ) mFileOperations->dropExternalFiles(); - } else if ( event->Type == InputEvent::MouseButtonDown ) { - // Any press inside a pane (or its location bar) makes it the active one. Widgets - // handle their own clicks, so this is tracked at the window level. - // The widget under the pointer must belong to the pane: menus and dialogs drawn over - // it are not the pane. - // Use the dispatcher's view coordinates: native button events can use logical - // coordinates while the UI uses drawable pixels on high-DPI displays. - Vector2f mouse = mApp->getWindow()->getInput()->getMousePosFromView( - mApp->getWindow()->getDefaultView() ); - Node* over = mUI->overFind( mouse ); - for ( int i = 0; i < 2; ++i ) { - Pane& p = mPaneManager->pane( i ); - if ( over && ( ( p.root->getParent() == mPaneSplitter && - ( p.root == over || p.root->inParentTreeOf( over ) ) ) || - ( p.location.box->isVisible() && - ( p.location.box == over || - p.location.box->inParentTreeOf( over ) ) ) ) ) { - if ( i != mPaneManager->activeIndex() ) - mPaneManager->setActivePane( i ); - break; - } - } } else if ( event->Type == InputEvent::MouseButtonUp ) { // Side mouse buttons navigate the history. if ( event->button.button == EE_BUTTON_4 ) @@ -654,6 +643,7 @@ bool App::init( const std::vector& locations, bool forceSplit ) { return false; } ); + // Keyboard focus determines the active pane; scrolling and background operations do not. // The keyboard belongs to the files: a click on a pane's bars or empty areas focuses the // container under the pointer, which would swallow the keys (typing, arrows, Enter). Hand // it to that pane's files on the next frame. Buttons and text fields keep it. @@ -671,31 +661,30 @@ bool App::init( const std::vector& locations, bool forceSplit ) { } if ( !focus || !focus->isWidget() ) return; - for ( int i = 0; i < 2; ++i ) { - auto* tab = mPaneManager->currentTab( mPaneManager->pane( i ) ); - if ( !tab || !mPaneManager->pane( i ).root->inParentTreeOf( focus ) ) - continue; - // Row clicks focus a cell (a push button), not the view itself. Keep file commands - // on that pane before leaving the focused control in charge of its own input. - if ( i != mPaneManager->activeIndex() ) - mPaneManager->setActivePane( i ); - if ( focus->isType( UI_TYPE_TEXTINPUT ) || tab->view == focus || - tab->view->inParentTreeOf( focus ) ) - return; - // Buttons and their parts (a tab's close button): the click that follows goes to - // the focused node, it must stay there. - for ( Node* node = focus; node; node = node->getParent() ) { - if ( node->isType( UI_TYPE_PUSHBUTTON ) ) - return; - } - mUI->runOnMainThread( [this, i, focus] { - auto* current = mPaneManager->currentTab( mPaneManager->pane( i ) ); - // Only if nothing else took the focus meanwhile (focus is not dereferenced). - if ( current && mUI->getEventDispatcher()->getFocusNode() == focus ) - current->view->setFocus(); - } ); + Pane* pane = mPaneManager->paneForNode( focus ); + if ( !pane ) return; + auto* tab = mPaneManager->currentTab( *pane ); + if ( !tab ) + return; + // Row clicks focus a cell (a push button), not the view itself. Refresh the pane's + // decorations without replacing the focused control that must receive the click. + mPaneManager->onFocusChange(); + if ( focus->isType( UI_TYPE_TEXTINPUT ) || tab->view == focus || + tab->view->inParentTreeOf( focus ) ) + return; + // Buttons and their parts (a tab's close button): the click that follows goes to + // the focused node, it must stay there. + for ( Node* node = focus; node; node = node->getParent() ) { + if ( node->isType( UI_TYPE_PUSHBUTTON ) ) + return; } + mUI->runOnMainThread( [this, i = pane->index, focus] { + auto* current = mPaneManager->currentTab( mPaneManager->pane( i ) ); + // Only if nothing else took the focus meanwhile (focus is not dereferenced). + if ( current && mUI->getEventDispatcher()->getFocusNode() == focus ) + current->view->setFocus(); + } ); } ); mPaneManager->restoreTabs( mState, locations, forceSplit ); @@ -774,7 +763,7 @@ void App::setSplit( bool split ) { } else if ( !split && right.root->getParent() == mPaneSplitter ) { right.root->setParent( mParking ); if ( mPaneManager->activeIndex() == 1 ) - mPaneManager->setActivePane( 0 ); + mPaneManager->focusPane( 0 ); } right.location.box->setVisible( split ); if ( auto* button = mUI->find( "split" ) ) @@ -1002,9 +991,7 @@ void App::registerShortcuts() { mUI->setKeyBindingCommand( "switch-pane", fileCommand( [this] { if ( !mSplit ) return; - mPaneManager->setActivePane( 1 - mPaneManager->activeIndex() ); - if ( auto* tab = mPaneManager->currentTab() ) - tab->view->setFocus(); + mPaneManager->focusPane( 1 - mPaneManager->activeIndex() ); } ) ); mUI->setKeyBindingCommand( "refresh", onTab( []( FileTab* tab ) { tab->model->refresh(); } ) ); mUI->setKeyBindingCommand( "go-back", [this] { mPaneManager->goBack(); } ); diff --git a/src/tools/eefm/fileoperations.cpp b/src/tools/eefm/fileoperations.cpp index 4f2988661..ef80900a8 100644 --- a/src/tools/eefm/fileoperations.cpp +++ b/src/tools/eefm/fileoperations.cpp @@ -1094,7 +1094,7 @@ FileOperations::dropDestination( Vector2f position ) { if ( auto row = tab->view->rowOf( over ); row && *row < tab->model->rowCount() && tab->model->entry( *row ).isDirectory() ) folder = tab->model->entryURI( *row ); - mApp.panes().setActivePane( target ); + mApp.panes().focusPane( target ); return DropDestination{ std::move( folder ), target }; } diff --git a/src/tools/eefm/panemanager.cpp b/src/tools/eefm/panemanager.cpp index 04998040d..8387a942b 100644 --- a/src/tools/eefm/panemanager.cpp +++ b/src/tools/eefm/panemanager.cpp @@ -85,6 +85,12 @@ PaneManager::PaneManager( App& app ) : mApp( app ), mUI( app.ui() ) {} PaneManager::~PaneManager() = default; +int PaneManager::activeIndex() const { + if ( auto* focused = paneForNode( mUI->getEventDispatcher()->getFocusNode() ) ) + return focused->index; + return mLastFocusedPane; +} + void PaneManager::clearTabs() { for ( auto& pane : mPanes ) { if ( pane ) @@ -141,7 +147,7 @@ void PaneManager::createPane( int index, UIWidget* parent ) { part->on( Event::MouseClick, [this, index]( const Event* event ) { if ( !( static_cast( event )->getFlags() & EE_BUTTON_RMASK ) ) return; - setActivePane( index ); + focusPane( index ); if ( auto* tab = currentTab( pane( index ) ) ) mApp.fileOps().showContextMenu( tab, true ); } ); @@ -215,7 +221,7 @@ void PaneManager::createPane( int index, UIWidget* parent ) { if ( mMovingTab && mMovingTab->tab == tabEvent->getTab() ) { mMovingTab->pane = &pane; pane.tabList.push_back( std::move( mMovingTab ) ); - setActivePane( index ); + focusPane( index ); } positionNewTabButton( pane ); } ); @@ -236,7 +242,7 @@ void PaneManager::createPane( int index, UIWidget* parent ) { mApp.shortcutTooltip( mUI->i18n( "eefm_new_tab", "New Tab" ), "new-tab" ) ); p.newTabButton->onClick( [this, index]( const MouseEvent* ) { Pane& pane = this->pane( index ); - setActivePane( index ); + focusPane( index ); auto* tab = currentTab( pane ); openTab( pane, tab ? tab->model->uri() : URI::fromFSPath( Sys::getUserDirectory() ) ); } ); @@ -252,8 +258,6 @@ void PaneManager::createPane( int index, UIWidget* parent ) { tab->view->setFocus(); } } ); - p.location.input->on( Event::OnFocus, - [this, index]( const Event* ) { setActivePane( index ); } ); frame->findByClass( "location_clear" )->onClick( [this, index]( const MouseEvent* ) { auto* input = this->pane( index ).location.input; input->setText( "" ); @@ -320,7 +324,6 @@ void PaneManager::createPane( int index, UIWidget* parent ) { applyFilter( tab ); } } ); - p.filterInput->on( Event::OnFocus, [this, index]( const Event* ) { setActivePane( index ); } ); // Escape returns to the files (the filter and the selection stay). p.filterInput->on( Event::KeyDown, [this, index]( const Event* event ) { if ( event->asKeyEvent()->getKeyCode() != KEY_ESCAPE ) @@ -344,6 +347,11 @@ FileTab* PaneManager::currentTab( Pane& pane ) { return nullptr; } +FileTab* PaneManager::currentTab() { + auto* owner = mPanes[activeIndex()].get(); + return owner ? currentTab( *owner ) : nullptr; +} + FileTab* PaneManager::findTab( UITab* uitab ) { for ( auto& pane : mPanes ) { for ( auto& tab : pane->tabList ) { @@ -354,13 +362,40 @@ FileTab* PaneManager::findTab( UITab* uitab ) { return nullptr; } -void PaneManager::setActivePane( int index ) { +Pane* PaneManager::paneForNode( const Node* node ) const { + if ( !node ) + return nullptr; + for ( const auto& pane : mPanes ) { + if ( !pane ) + continue; + for ( auto* root : { pane->root, pane->location.box } ) { + if ( root && ( root == node || root->isParentOf( node ) ) ) + return pane.get(); + } + } + return nullptr; +} + +void PaneManager::focusPane( int index ) { if ( index == 1 && !mApp.isSplit() ) index = 0; - mActivePane = index; + Pane& target = pane( index ); + if ( paneForNode( mUI->getEventDispatcher()->getFocusNode() ) != &target ) { + if ( auto* tab = currentTab( target ) ) + tab->view->setFocus(); + } + // Startup and tab restoration can already have placed focus here before the tab was ready. + onFocusChange(); +} + +void PaneManager::onFocusChange() { + Pane* focused = paneForNode( mUI->getEventDispatcher()->getFocusNode() ); + if ( !focused || ( mLastFocusedPane == focused->index && focused->root->hasClass( "active" ) ) ) + return; + mLastFocusedPane = focused->index; for ( int i = 0; i < 2; ++i ) { Pane& p = pane( i ); - if ( i == index ) { + if ( &p == focused ) { p.root->addClass( "active" ); p.location.box->addClass( "active" ); } else { @@ -368,7 +403,7 @@ void PaneManager::setActivePane( int index ) { p.location.box->removeClass( "active" ); } } - updateChrome( activePane() ); + updateChrome( *focused ); } void PaneManager::positionNewTabButton( Pane& target ) { @@ -447,7 +482,7 @@ FileTab* PaneManager::openTab( Pane& pane, const URI& uri, bool select ) { }; view->on( Event::OnSizeChange, [capColumns]( const Event* ) { capColumns(); } ); view->onModelEvent( [this, raw]( const ModelEvent* event ) { - setActivePane( raw->pane->index ); + focusPane( raw->pane->index ); if ( event->getModelEventType() == ModelEventType::Open ) { mApp.fileOps().openRow( raw, event->getModelIndex().row() ); } else if ( event->getModelEventType() == ModelEventType::OpenMenu ) { @@ -460,7 +495,6 @@ FileTab* PaneManager::openTab( Pane& pane, const URI& uri, bool select ) { // Middle click opens a folder in a new tab, in the background (as Dolphin and browsers); // with the modifier (Ctrl, Cmd on macOS) the new tab is selected and focused. view->onRowMiddleClick = [this, raw]( size_t row ) { - setActivePane( raw->pane->index ); if ( row >= raw->model->rowCount() || !raw->model->entry( row ).isDirectory() ) return; bool select = @@ -474,7 +508,7 @@ FileTab* PaneManager::openTab( Pane& pane, const URI& uri, bool select ) { updateStatus( *raw->pane ); } ); view->onBackgroundMenu = [this, raw] { - setActivePane( raw->pane->index ); + focusPane( raw->pane->index ); mApp.fileOps().showContextMenu( raw, true ); }; view->dragPayload = [this, raw] { return raw->selectedURIs(); }; @@ -492,8 +526,6 @@ FileTab* PaneManager::openTab( Pane& pane, const URI& uri, bool select ) { mApp.fileOps().dropFiles( FileTableView::currentDrag().uris, destination, raw->pane->index ); }; - view->on( Event::OnFocus, [this, raw]( const Event* ) { setActivePane( raw->pane->index ); } ); - raw->tab = pane.tabs->add( Location::displayName( uri ), view, mApp.icon( "mime-folder" ) ); // The new tab button would sit over a dragged tab: hidden while one moves (a press alone // starts a drag, so it waits for the movement), back in place after. @@ -646,7 +678,7 @@ void PaneManager::updateChrome( Pane& pane ) { if ( !pane.location.input->hasFocus() ) pane.location.input->setText( Location::text( uri ) ); updateStatus( pane ); - if ( pane.index != mActivePane ) + if ( pane.index != activeIndex() ) return; mUI->find( "back" )->setEnabled( tab->historyIndex > 0 ); mUI->find( "forward" )->setEnabled( tab->historyIndex + 1 < tab->history.size() ); @@ -861,10 +893,8 @@ void PaneManager::restoreTabs( const nlohmann::json& state, } mApp.setSplit( split ); // Start on the left pane, with the keyboard there too: restoring tabs focuses the views it - // selects (the right pane's last), and the highlight alone does not move the focus. - setActivePane( 0 ); - if ( auto* tab = currentTab( pane( 0 ) ) ) - tab->view->setFocus(); + // selects (the right pane's last). + focusPane( 0 ); } } // namespace eefm diff --git a/src/tools/eefm/panemanager.hpp b/src/tools/eefm/panemanager.hpp index 6f25c4659..63e73f6e8 100644 --- a/src/tools/eefm/panemanager.hpp +++ b/src/tools/eefm/panemanager.hpp @@ -28,9 +28,10 @@ class PaneManager { Pane& pane( int index ) { return *mPanes[index]; } - Pane& activePane() { return *mPanes[mActivePane]; } + Pane& activePane() { return pane( activeIndex() ); } - int activeIndex() const { return mActivePane; } + /** @return The pane owning keyboard focus, or the last focused pane while other UI owns it. */ + int activeIndex() const; /** @return True once both panes exist. */ bool ready() const { return mPanes[0] && mPanes[1]; } @@ -39,10 +40,13 @@ class PaneManager { FileTab* currentTab( Pane& pane ); - FileTab* currentTab() { return mPanes[mActivePane] ? currentTab( activePane() ) : nullptr; } + FileTab* currentTab(); FileTab* findTab( UITab* tab ); + /** @return The pane owning a node in its view or location bar, or nullptr for other UI. */ + Pane* paneForNode( const Node* node ) const; + FileTab* openTab( Pane& pane, const URI& uri, bool select = true ); void closeTab( FileTab* tab ); @@ -50,7 +54,11 @@ class PaneManager { /** Closes every tab (their models stop listing and watching). */ void clearTabs(); - void setActivePane( int index ); + /** Gives a pane's files keyboard focus unless one of its controls already owns it. */ + void focusPane( int index ); + + /** Updates pane decorations and remembers the pane owning the current keyboard focus. */ + void onFocusChange(); void navigate( FileTab* tab, URI uri, bool recordHistory = true ); @@ -98,7 +106,9 @@ class PaneManager { std::unique_ptr mMovingTab; /** Column widths / visibility / sort shared by new tabs. */ nlohmann::json mViewState; - int mActivePane{ 0 }; + /** Command context while a toolbar, popup, dialog, places or terminal owns keyboard focus. + * Only updated from actual pane focus; activeIndex() always prefers the current focus. */ + int mLastFocusedPane{ 0 }; EE::VFS::VFSDirectoryModel::NameFilterMode mFilterMode{ EE::VFS::VFSDirectoryModel::NameFilterMode::Glob }; bool mFilterCaseSensitive{ false };