Add tabbed HTML browsing and extend eepp navigation and input support

eepp framework:
- Add shared cookie jars to UISceneNode and UIWebView, plus versioned
  CookieManager persistence, domain clearing, and locking in hasCookie().
- Follow HTTP 303 redirects and merge same-host redirect cookies as
  name/value pairs without forwarding Set-Cookie attributes.
- Include Cookie headers in document cache keys while preserving static
  resource reuse as cookies change.
- Submit HTML forms with an empty action to the current document URL.
- Extend NavigationRequest with source, mouse buttons, modifiers, and
  navigation target; anchors forward this context to the scene.
- Emit OnLinkOpenRequested for middle-click, platform-modifier click,
  and explicit new-tab requests, with current-view fallback if unhandled.
- Install the WebView navigation interceptor during construction.
- Dispatch middle clicks to the hovered node without moving keyboard focus.
- Add the clicked target to ContextMenuEvent so applications can extend
  context menus through existing events.
- Parse HTML titles, emit TitleEvent, and fix onTitleChanged's payload cast.
- Load declared favicons through the shared image cache, emit
  OnFaviconChanged, and ignore responses from superseded navigation.
- Add optional UITabWidgetSplitter tab-bar visibility while split;
  keep it disabled by default and respect explicitly hidden tab bars.
- Add Space/Shift+Space scrolling and viewport-sized Page Up/Down steps
  to UIScrollView, shared by WebView and Markdown content.
- Implement setEnableDefaultKeybindings() and preserve text-input
  priority through normal bubbling without control-type checks.
- Report inserted UICodeEditor text as handled.
- Add UITextView::onFontColorChanged() and synchronize password-mask color.
- Support validated left, middle, and right clicks in the inspector
  server and Python client.

ui_html example:
- Use UITabWidgetSplitter for tabs and user-controlled splits.
- Share cookies and the resource cache across tabs; persist cookies
  between sessions and open new tabs blank.
- Add Inspect, Clear Domain Cookies, Clear All Cookies, and contextual
  Open Link in New Tab actions through menu event subscriptions.
- Open requested links in background tabs and synchronize navigation UI.
- Use document titles and 16dp favicons for tabs, cap width at 200dp,
  and truncate long titles with ellipsis.
- Enable tab-bar visibility across split panes.
- Fixed: the selection rectangle now uses the visible password symbols instead of the original text.

Update HTML compatibility and inspector documentation and add regression
coverage for persistence, redirects, cache isolation, navigation, menus,
titles, favicons, password colors, split visibility, and scrolling.
This commit is contained in:
Martín Lucas Golini
2026-09-29 18:58:51 -03:00
parent a090d1a666
commit a8eb1834eb
37 changed files with 1261 additions and 92 deletions
+30
View File
@@ -1005,8 +1005,36 @@ back into the owning `UIWebView`, including relative URL resolution.
This means ordinary links can navigate the current web view.
`NavigationRequest` carries the source node, mouse buttons, and keyboard modifiers. Middle click,
the platform modifier plus left click, or an explicit `Target::NewTab` request emits
`Event::OnLinkOpenRequested` on the WebView. Subscribe to this event, open the resolved URI in a
new view, and call `UIWebView::LinkOpenEvent::accept()` to handle it. Unhandled requests navigate
the current view. Applications add their own link menu actions through `OnCreateContextMenu`;
`ContextMenuEvent::getTarget()` identifies the clicked document node.
`getTitle()` returns the parsed HTML document title. `onTitleChanged()` reports the title after
loading, including an empty title for pages without one. The `ui_html` example uses it for tab labels
and falls back to the host or URL. Tabs cap their width at 200dp and truncate long titles with ellipsis.
The example enables `UITabWidgetSplitter::setShowTabBarWhenSplit(true)` to keep each pane's tab bar
visible while split. This option is disabled by default for other splitter users.
`<link rel="icon" href="...">` (including `rel="shortcut icon"`) loads the declared favicon through
the document's shared image cache. Relative URLs resolve against the document URL, and SVG icons
are supported. `Event::OnFaviconChanged` carries a `UIWebView::FaviconEvent` with the loaded texture;
an empty icon clears the previous document's favicon. Responses from an older navigation are ignored.
The `ui_html` example displays the texture as a 16dp tab icon.
`UIWebView` maintains its own navigation history.
`UIMarkdownView` uses the same shortcuts through its enclosing `UIScrollView`; disable them on
that scroll view with `setEnableDefaultKeybindings(false)`.
Default scrolling shortcuts are Space and Page Down (one viewport down), Shift+Space and Page Up
(one viewport up). Keys handled by a focused descendant do not reach the WebView. While text input is active,
Space scrolls only if its committed text reaches the WebView unhandled; custom controls can consume
it through `onTextInput()` like built-in editors. Call `setEnableDefaultKeybindings(false)` to disable scrolling
shortcuts, including the inherited arrow and Home/End bindings.
Anchor interaction also cooperates with document text selection so a completed text drag does not
accidentally activate the link.
@@ -1074,6 +1102,8 @@ other text-like types -> UIHTMLTextInput
Forms collect named control values and can submit navigation requests.
An omitted or empty form `action` submits to the full current document URL, preserving its query.
Supported submission encodings include:
```text
+16 -2
View File
@@ -54,6 +54,7 @@ python3 projects/scripts/eepp-inspect.py focus --scene scene:2
python3 projects/scripts/eepp-inspect.py screenshot --scene scene:2 --format png
python3 projects/scripts/eepp-inspect.py screenshot --window win:2 --rect 10 20 400 300 --format webp
python3 projects/scripts/eepp-inspect.py click --scene scene:2 '#submit'
python3 projects/scripts/eepp-inspect.py click --button right --scene scene:2 '#content'
python3 projects/scripts/eepp-inspect.py key --scene scene:2 Enter
python3 projects/scripts/eepp-inspect.py type --scene scene:2 'hello world'
python3 projects/scripts/eepp-inspect.py type --target '#search' 'hello world'
@@ -114,7 +115,7 @@ All examples below omit `id` where the surrounding explanation is enough; real r
| `ui.inspect` | Exactly one of `handle` or `selector` plus optional `scene`; optional `properties`, `maxStringLength:4096` (maximum 65536) | Handle, scene, typed `properties`, `unavailable` | `invalid-widget`, `target-not-found`, `target-ambiguous` |
| `ui.focus` | `scene` default | Scene and focused widget summary or `null` | `invalid-scene` |
| `ui.screenshot` | Optional `scene` or `window` (mutually exclusive); optional `rect:[x,y,width,height]`; `format:"png"` | Temporary file path, format, window/scene handles, captured rectangle and size | `invalid-window`, `invalid-scene`, `invalid-params`, `screenshot-failed` |
| `input.click` | Exactly one target; optional `button:"left"`, `count:1` (maximum 2) | Target handle, scene, click point | `target-not-interactable`, `permission-denied` |
| `input.click` | Exactly one target; optional `button:"left"`, `"middle"`, or `"right"`, `count:1` (maximum 2) | Target handle, scene, click point | `target-not-interactable`, `permission-denied` |
| `input.key` | `scene` default, `key` required, `action:"press"`, `modifiers:[]` | Key and action | `invalid-params`, `permission-denied` |
| `input.text` | `scene` default, `text` required (maximum 64 KiB UTF-8) | Character count | `invalid-params`, `permission-denied` |
| `watch.subscribe` | One handle or selector; explicit `properties` array; `initial:true` | Subscription and bound target values | `target-not-found`, `property-unknown` |
@@ -159,7 +160,20 @@ These compact request/response pairs show each method's envelope. Handle numbers
`ui.screenshot` captures the native window containing the selected scene. With no target it captures the default scene's full native window. An explicit `window` captures that whole window; an explicit `scene` crops to its visible world bounds, including a WebView document viewport. `rect` overrides that crop and uses top-left native-window pixel coordinates. It must fit entirely inside the window and have positive width and height. Supported formats are `png`, `jpg`, `bmp`, `tga`, `qoi`, and `webp`; eepp's case-insensitive image extension parser also accepts `jpeg` and `jfif` as aliases for `jpg`. The response uses the canonical extension. The server renders the selected window after the current UI update, saves the image in the OS temporary directory, and returns its absolute `path`; it does not send image bytes over NDJSON. The caller is responsible for deleting the file when finished. The path is useful to a local agent; remote clients must separately retrieve the file, for example over SSH. Screenshot observation is available in read-only mode and can be used inside `session.batch`.
Single-widget methods never pick the first of multiple selector matches: zero matches return `target-not-found`, and multiple matches return `target-ambiguous`. `input.click` uses the window input route and scene hit testing; clipped, covered, invisible, disabled, or off-window targets can return `target-not-interactable`. It does not scroll a target into view. `input.key` accepts names understood by eepp's `Input::getKeyFromName()` plus `Enter` as an alias for `Return`, and modifiers `Ctrl`, `Shift`, `Alt`, `Meta`; action may be `press`, `down`, or `up`. `input.text` sends Unicode text input to the current focus path and does not focus a widget first. Read-only mode omits input capabilities and returns `permission-denied` for these methods.
Single-widget methods never pick the first of multiple selector matches: zero matches return `target-not-found`, and multiple matches return `target-ambiguous`. `input.click` uses the window input route and scene hit testing; clipped, covered, invisible, disabled, or off-window targets can return `target-not-interactable`. It does not scroll a target into view. The default button is `left`; set `button` to `middle` for a middle click or `right` to open a context menu. Other button values return `invalid-params`. `input.key` accepts names understood by eepp's `Input::getKeyFromName()` plus `Enter` as an alias for `Return`, and modifiers `Ctrl`, `Shift`, `Alt`, `Meta`; action may be `press`, `down`, or `up`. `input.text` sends Unicode text input to the current focus path and does not focus a widget first. Read-only mode omits input capabilities and returns `permission-denied` for these methods.
### Checking context menus
Right-click a visible target, then query the popup and its items. For a WebView, the HTML target belongs to its document scene, while the popup usually belongs to the parent application scene:
```sh
python3 projects/scripts/eepp-inspect.py contexts
python3 projects/scripts/eepp-inspect.py click --button right --scene scene:2 '#context-target'
python3 projects/scripts/eepp-inspect.py query --scene scene:1 'popupmenu'
python3 projects/scripts/eepp-inspect.py tree --scene scene:1 --depth 5
```
`input.click` aims at the target's center. Choose a visible child if a large HTML element extends beyond the window; a body whose center is off-screen will return `target-not-interactable`. The command reports the chosen point in window pixels. The same button option works in a raw or batched `input.click` request: `{"method":"input.click","params":{"scene":"scene:2","selector":"#context-target","button":"right"}}`.
## Properties
+8
View File
@@ -25,6 +25,14 @@ class EE_API CookieManager {
/** Remove all stored cookies. */
void clear();
/** Remove cookies stored for exactly this domain. */
void clearDomain( const std::string& domain );
/** Load or save this jar to a versioned file. Loading replaces the current contents. */
bool loadFromFile( const std::string& path );
bool saveToFile( const std::string& path ) const;
/** @return The number of cookie entries across all domains. */
size_t size() const;
+2
View File
@@ -137,6 +137,8 @@ class EE_API Event {
OnHideFindReplace,
OnTooltipCreated,
MouseWheel,
OnLinkOpenRequested,
OnFaviconChanged,
NoEvent = eeINDEX_NOT_FOUND
};
@@ -120,6 +120,12 @@ class EE_API UITabWidgetSplitter {
void setHideTabBarOnSingleTab( bool hideTabBarOnSingleTab );
/** Whether splitting keeps single-tab bars visible. Disabled by default.
* Explicitly hidden tab bars (setHideTabBar) remain hidden. */
bool getShowTabBarWhenSplit() const;
void setShowTabBarWhenSplit( bool showTabBarWhenSplit );
void setHideTabBar( bool hideTabBar );
bool getVisualSplitting() const;
@@ -255,6 +261,7 @@ class EE_API UITabWidgetSplitter {
bool mHideTabBar{ false };
bool mHideTabBarOnSingleTab{ true };
bool mVisualSplitting{ true };
bool mShowTabBarWhenSplit{ false };
Float mVisualSplitEdgePercent{ 0.1 };
Mutex mTabWidgetMutex;
std::function<void( UITabWidget* )> mOnTabWidgetCreateCb;
@@ -274,6 +281,8 @@ class EE_API UITabWidgetSplitter {
void detachWidgetEvents( UIWidget* widget );
void updateTabBarVisibility();
void closeAllTabs( std::vector<UITab*> tabs, UITabWidget::FocusTabBehavior focusTabBehavior );
void updateTabWidgetVisualSplitting();
+6 -2
View File
@@ -48,13 +48,17 @@ class EE_API UIPopUpMenu : public UIMenu {
class EE_API ContextMenuEvent : public MouseEvent {
public:
ContextMenuEvent( Node* node, UIPopUpMenu* menu, const Uint32& eventType, const Vector2i& pos,
const Uint32& flags ) :
MouseEvent( node, eventType, pos, flags ), menu( menu ) {}
const Uint32& flags, const Node* target = nullptr ) :
MouseEvent( node, eventType, pos, flags ), menu( menu ), target( target ) {}
UIPopUpMenu* getMenu() const { return menu; }
/** The widget under the pointer, when the menu creator provides one. */
const Node* getTarget() const { return target; }
protected:
UIPopUpMenu* menu;
const Node* target;
};
}} // namespace EE::UI
+14 -3
View File
@@ -44,10 +44,16 @@ class UIIcon;
class UIRoot;
struct NavigationRequest {
enum class Target : Uint8 { Current, NewTab };
URI uri;
std::string method{ "GET" };
std::string body;
std::map<std::string, std::string> extraHeaders;
const Node* source{ nullptr };
Uint32 mouseButtons{ 0 };
Uint32 modifiers{ 0 };
Target target{ Target::Current };
};
class EE_API UISceneNode : public SceneNode {
@@ -931,9 +937,14 @@ class EE_API UISceneNode : public SceneNode {
/** @return The document referer */
URI getReferer() const { return mReferer; };
const Network::CookieManager& getCookieManager() const { return mCookieManager; }
const CookieManager& getCookieManager() const { return *mCookieManager; }
Network::CookieManager& getCookieManager() { return mCookieManager; }
CookieManager& getCookieManager() { return *mCookieManager; }
/** Share one cookie jar across document scenes. Passing nullptr creates a fresh private jar. */
void setCookieManager( std::shared_ptr<CookieManager> manager ) {
mCookieManager = manager ? std::move( manager ) : std::make_shared<CookieManager>();
}
const WebResourceCachePtr& getWebResourceCache() const { return mWebResourceCache; }
@@ -1057,7 +1068,7 @@ class EE_API UISceneNode : public SceneNode {
URI mURI;
URI mReferer;
std::function<bool( const NavigationRequest& request )> mNavigationInterceptorCb;
Network::CookieManager mCookieManager;
std::shared_ptr<CookieManager> mCookieManager{ std::make_shared<CookieManager>() };
/**
* @brief Protected constructor.
+5
View File
@@ -52,6 +52,7 @@ class EE_API UIScrollView : public UITouchDraggableWidget {
void setAnchorScroll( bool anchor );
/** Enable default scrolling shortcuts. Enabled by default. */
void setEnableDefaultKeybindings( bool enable );
bool areDefaultKeybindingsEnabled() const { return mDefaultKeybindings; }
@@ -92,6 +93,10 @@ class EE_API UIScrollView : public UITouchDraggableWidget {
virtual Uint32 onKeyDown( const KeyEvent& event );
virtual Uint32 onTextInput( const TextInputEvent& event );
void scrollByViewport( Float direction );
void onValueChangeCb( const Event* Event );
virtual void onScrollViewSizeChange( const Event* Event );
+2
View File
@@ -201,6 +201,8 @@ class EE_API UITextInput : public UITextView, public TextDocument::Client {
virtual void onFontStyleChanged();
virtual void onFontColorChanged();
void onThemeLoaded();
virtual void onCursorPosChange();
+2
View File
@@ -179,6 +179,8 @@ class EE_API UITextView : public UIWidget {
virtual void onFontStyleChanged();
virtual void onFontColorChanged();
virtual void onAlphaChange();
virtual void onPaddingChange();
+36
View File
@@ -17,6 +17,10 @@
using namespace EE::Network;
namespace EE { namespace Network {
class CookieManager;
}} // namespace EE::Network
namespace EE { namespace UI {
class UIHTMLHtml;
@@ -26,6 +30,31 @@ class UISceneNode;
class EE_API UIWebView : public UIScrollView {
public:
/** The icon is empty when the replacement document has no favicon yet. */
struct FaviconEvent : Scene::Event {
TexturePtr icon;
FaviconEvent( Node* node, TexturePtr icon = {} ) :
Scene::Event( node, Event::OnFaviconChanged ), icon( std::move( icon ) ) {}
};
struct TitleEvent : Scene::Event {
std::string title;
TitleEvent( Node* node, std::string title ) :
Scene::Event( node, Event::OnTitleChanged ), title( std::move( title ) ) {}
};
struct LinkOpenEvent : Scene::Event {
URI uri;
mutable bool handled{ false };
LinkOpenEvent( Node* node, URI uri ) :
Scene::Event( node, Event::OnLinkOpenRequested ), uri( std::move( uri ) ) {}
void accept() const { handled = true; }
};
struct NavigationEvent : Scene::Event {
URI uri;
bool success{ false };
@@ -72,6 +101,8 @@ class EE_API UIWebView : public UIScrollView {
const URI& getCurrentURI() const;
const std::string& getTitle() const { return mTitle; }
void reload();
UIWidget* getDocumentContainer() const;
@@ -82,6 +113,9 @@ class EE_API UIWebView : public UIScrollView {
UIWebView* setWebResourceCache( WebResourceCachePtr cache, CachePartitionId partition = 0 );
/** Share a cookie jar with other WebViews. Set before starting navigation. */
UIWebView* setCookieManager( std::shared_ptr<EE::Network::CookieManager> manager );
void setStyleSheetDefaultMarker( Uint32 marker );
void setUserAgent( const std::string& userAgent );
@@ -127,6 +161,7 @@ class EE_API UIWebView : public UIScrollView {
bool mIsLoading{ false };
Uint64 mNavigationGeneration{ 0 };
std::string mUserAgent;
std::string mTitle;
Time mDefaultTimeout{ Seconds( 30 ) };
Time mWebResourceCachePruneElapsed;
Uint32 mStyleSheetDefaultMarker{ 0 };
@@ -141,6 +176,7 @@ class EE_API UIWebView : public UIScrollView {
virtual void scheduledUpdate( const Time& time );
virtual Uint32 onKeyDown( const KeyEvent& event );
virtual Uint32 onMessage( const NodeMessage* message );
virtual void onScrollViewSizeChange( const Event* event );
+4 -1
View File
@@ -110,7 +110,9 @@ def command(args):
params["format"] = args.format
return "ui.screenshot", params
if name == "click":
return "input.click", target(args.target, args.scene)
params = target(args.target, args.scene)
params["button"] = args.button
return "input.click", params
if name == "key":
params.update(key=args.key, action=args.action, modifiers=args.modifiers)
return "input.key", params
@@ -168,6 +170,7 @@ def main():
click = sub.add_parser("click")
click.add_argument("target")
click.add_argument("--scene")
click.add_argument("--button", choices=("left", "middle", "right"), default="left")
key = sub.add_parser("key")
key.add_argument("key")
key.add_argument("--scene")
+60 -2
View File
@@ -1,12 +1,14 @@
#include <eepp/network/cookiemanager.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/lock.hpp>
#include <fstream>
#include <iomanip>
namespace EE { namespace Network {
CookieManager::CookieManager() {}
void CookieManager::storeCookies( const std::string& domain,
const Http::Response& response ) {
void CookieManager::storeCookies( const std::string& domain, const Http::Response& response ) {
std::string setCookie = response.getField( "set-cookie" );
if ( !setCookie.empty() )
parseSetCookie( domain, setCookie );
@@ -38,6 +40,61 @@ void CookieManager::clear() {
mCookies.clear();
}
void CookieManager::clearDomain( const std::string& domain ) {
Lock l( mMutex );
mCookies.erase( domain );
}
bool CookieManager::loadFromFile( const std::string& path ) {
std::ifstream file( path, std::ios::binary );
if ( !file )
return false;
std::string version;
if ( !( file >> version ) || version != "eepp-cookies-1" )
return false;
UnorderedMap<std::string, std::map<std::string, std::string>> cookies;
std::string domain, name, value;
while ( file >> std::quoted( domain ) ) {
if ( !( file >> std::quoted( name ) >> std::quoted( value ) ) || domain.empty() ||
name.empty() )
return false;
cookies[domain][name] = value;
}
if ( !file.eof() )
return false;
Lock l( mMutex );
mCookies = std::move( cookies );
return true;
}
bool CookieManager::saveToFile( const std::string& path ) const {
const std::string temporaryPath = path + ".tmp";
{
std::ofstream file( temporaryPath, std::ios::binary | std::ios::trunc );
if ( !file )
return false;
file << "eepp-cookies-1\n";
{
Lock l( mMutex );
for ( const auto& domain : mCookies ) {
for ( const auto& cookie : domain.second )
file << std::quoted( domain.first ) << ' ' << std::quoted( cookie.first ) << ' '
<< std::quoted( cookie.second ) << '\n';
}
}
file.flush();
if ( !file ) {
FileSystem::fileRemove( temporaryPath );
return false;
}
}
if ( !FileSystem::fileMove( temporaryPath, path ) ) {
FileSystem::fileRemove( temporaryPath );
return false;
}
return true;
}
size_t CookieManager::size() const {
Lock l( mMutex );
size_t total = 0;
@@ -73,6 +130,7 @@ void CookieManager::parseSetCookie( const std::string& domain,
}
bool CookieManager::hasCookie( const std::string& domain ) const {
Lock l( mMutex );
return mCookies.find( domain ) != mCookies.end();
}
+33 -3
View File
@@ -1061,6 +1061,7 @@ Http::Response Http::downloadRequest( const Http::Request& request, IOStream& wr
// redirections, send a new request to the redirection location.
if ( ( received.getStatus() == Response::MovedPermanently ||
received.getStatus() == Response::MovedTemporarily ||
received.getStatus() == Response::SeeOther ||
received.getStatus() == Response::PermanentRedirect ||
received.getStatus() == Response::TemporaryRedirect ) &&
request.getFollowRedirect() ) {
@@ -1094,9 +1095,38 @@ Http::Response Http::downloadRequest( const Http::Request& request, IOStream& wr
newRequest.setProgressCallback(
request.getProgressCallback() );
if ( received.hasField( "set-cookie" ) ) {
newRequest.setField(
"Cookie", received.getField( "set-cookie" ) );
if ( received.hasField( "set-cookie" ) &&
( uri.getHost().empty() ||
uri.getHost() == getHostName() ) ) {
const std::string& setCookie =
received.getField( "set-cookie" );
std::string_view cookiePair = String::trim(
std::string_view( setCookie )
.substr( 0, setCookie.find( ';' ) ) );
const size_t equals = cookiePair.find( '=' );
if ( equals != std::string::npos && equals != 0 ) {
std::string cookieHeader( cookiePair );
std::string_view cookieName =
cookiePair.substr( 0, equals );
const std::string& previous =
request.getField( "cookie" );
String::readBySeparator(
previous,
[&]( std::string_view part ) {
std::string_view pair =
String::trim( part );
if ( !pair.empty() &&
pair.substr( 0,
pair.find( '=' ) ) !=
cookieName ) {
cookieHeader += "; ";
cookieHeader.append( pair.data(),
pair.size() );
}
},
';' );
newRequest.setField( "Cookie", cookieHeader );
}
}
request.mRedirectionCount++;
+14 -7
View File
@@ -186,7 +186,7 @@ void EventDispatcher::update( const Time& time ) {
}
if ( mInput->getReleaseTrigger() ) {
if ( NULL != mFocusNode ) {
if ( NULL != mFocusNode || ( mInput->getReleaseTrigger() & EE_BUTTON_MMASK ) ) {
if ( !nodeWasDragging || mMousePos == mLastMousePos ) {
// The focused node can change after the MouseUp ( since the node can call
// "setFocus()" on other node And the MouseClick would be received by the new
@@ -208,14 +208,21 @@ void EventDispatcher::update( const Time& time ) {
}
if ( mInput->getClickTrigger() && mDownNode == mOverNode ) {
mLastFocusNode->onMouseClick( mMousePosi, mInput->getClickTrigger() );
sendMsg( mLastFocusNode, NodeMessage::MouseClick, mInput->getClickTrigger() );
// Middle click should activate the hovered widget without changing keyboard
// focus.
Node* clickNode = ( mInput->getClickTrigger() & EE_BUTTON_MMASK )
? mOverNode
: mLastFocusNode;
if ( clickNode ) {
clickNode->onMouseClick( mMousePosi, mInput->getClickTrigger() );
sendMsg( clickNode, NodeMessage::MouseClick, mInput->getClickTrigger() );
}
if ( mInput->getDoubleClickTrigger() &&
if ( clickNode && mInput->getDoubleClickTrigger() &&
mClickPos.distance( mMousePosi ) < 10 ) {
mLastFocusNode->onMouseDoubleClick( mMousePosi,
mInput->getDoubleClickTrigger() );
sendMsg( mLastFocusNode, NodeMessage::MouseDoubleClick,
clickNode->onMouseDoubleClick( mMousePosi,
mInput->getDoubleClickTrigger() );
sendMsg( clickNode, NodeMessage::MouseDoubleClick,
mInput->getDoubleClickTrigger() );
}
+10 -4
View File
@@ -352,7 +352,7 @@ struct UIInspectorServer::Impl {
: "unauthenticated";
const char* message =
request.is_discarded() ? "Malformed JSON"
: isConnect ? "Invalid inspector token"
: isConnect ? "Invalid inspector token"
: "First request must be session.connect";
client.output +=
json( { { "id", id },
@@ -1068,8 +1068,14 @@ struct UIInspectorServer::Impl {
const std::string windowHandle = inspector.windowHandle( window );
if ( method == "input.click" ) {
const std::string targetHandle = inspector.widgetHandle( target );
if ( params.contains( "button" ) && params["button"] != "left" )
fail( "invalid-params", "Only left click is supported" );
if ( params.contains( "button" ) && !params["button"].is_string() )
fail( "invalid-params", "button must be a string" );
const std::string button = params.value( "button", "left" );
if ( button != "left" && button != "middle" && button != "right" )
fail( "invalid-params", "button must be left, middle, or right" );
const Uint8 inputButton = button == "right" ? EE_BUTTON_RIGHT
: button == "middle" ? EE_BUTTON_MIDDLE
: EE_BUTTON_LEFT;
unsigned count = boundedUnsigned( params, "count", 1, 2 );
auto bounds = target->getWorldBounds();
Vector2i point( static_cast<int>( ( bounds.Left + bounds.Right ) * 0.5f ),
@@ -1102,7 +1108,7 @@ struct UIInspectorServer::Impl {
InputEvent press( InputEvent::MouseButtonDown );
press.WinID = window->getWindowID();
press.button = {};
press.button.button = EE_BUTTON_LEFT;
press.button.button = inputButton;
press.button.x = point.x;
press.button.y = point.y;
input->pushEvent( press );
+23 -4
View File
@@ -180,8 +180,11 @@ UITabWidget* UITabWidgetSplitter::createTabWidget( Node* parent ) {
} );
if ( mOnTabWidgetCreateCb )
mOnTabWidgetCreateCb( tabWidget );
Lock l( mTabWidgetMutex );
mTabWidgets.push_back( tabWidget );
{
Lock l( mTabWidgetMutex );
mTabWidgets.push_back( tabWidget );
}
updateTabBarVisibility();
return tabWidget;
}
@@ -691,6 +694,7 @@ void UITabWidgetSplitter::onTabClosed( const TabEvent* tabEvent ) {
focusSomeWidget( nullptr );
}
updateTabBarVisibility();
eeASSERT( !mTabWidgets.empty() );
eeASSERT( !( mTabWidgets.size() == 1 && mTabWidgets[0]->getTabCount() == 0 ) );
return;
@@ -771,12 +775,27 @@ bool UITabWidgetSplitter::getHideTabBarOnSingleTab() const {
return mHideTabBarOnSingleTab;
}
void UITabWidgetSplitter::updateTabBarVisibility() {
const bool hideSingleTabBar = mHideTabBarOnSingleTab && !( mShowTabBarWhenSplit && hasSplit() );
for ( auto* widget : mTabWidgets )
widget->setHideTabBarOnSingleTab( hideSingleTabBar );
}
void UITabWidgetSplitter::setHideTabBarOnSingleTab( bool hideTabBarOnSingleTab ) {
if ( hideTabBarOnSingleTab != mHideTabBarOnSingleTab ) {
mHideTabBarOnSingleTab = hideTabBarOnSingleTab;
updateTabBarVisibility();
}
}
for ( auto widget : mTabWidgets )
widget->setHideTabBarOnSingleTab( hideTabBarOnSingleTab );
bool UITabWidgetSplitter::getShowTabBarWhenSplit() const {
return mShowTabBarWhenSplit;
}
void UITabWidgetSplitter::setShowTabBarWhenSplit( bool showTabBarWhenSplit ) {
if ( mShowTabBarWhenSplit != showTabBarWhenSplit ) {
mShowTabBarWhenSplit = showTabBarWhenSplit;
updateTabBarVisibility();
}
}
+3 -3
View File
@@ -1195,7 +1195,7 @@ Uint32 UICodeEditor::onTextInput( const TextInputEvent& event ) {
if ( plugin->onTextInput( this, event ) )
return 1;
return 0;
return 1;
}
void UICodeEditor::updateIMELocation() {
@@ -1920,8 +1920,8 @@ Uint32 UICodeEditor::onMouseUp( const Vector2i& position, const Uint32& flags )
}
Uint32 UICodeEditor::onMouseWheel( const Vector2f& offset, bool flipped ) {
const Vector2i position = getEventDispatcher() ? getEventDispatcher()->getMousePos()
: Vector2i::Zero;
const Vector2i position =
getEventDispatcher() ? getEventDispatcher()->getMousePos() : Vector2i::Zero;
for ( auto& plugin : mPlugins )
if ( plugin->onMouseWheel( this, position, offset, flipped ) )
return 1;
+3 -1
View File
@@ -83,7 +83,9 @@ void UIHTMLForm::submit() {
UISceneNode* sceneNode = getUISceneNode();
NavigationRequest request;
request.uri = URI( mAction );
// With no action, HTML submits to the current document URL, not its asset base URI.
request.uri = mAction.empty() ? sceneNode->getReferer() : URI( mAction );
request.source = this;
if ( mEnctype == "multipart/form-data" ) {
request.method = "POST";
+4 -4
View File
@@ -919,7 +919,7 @@ void UISceneNode::requestWebResource( WebResourceRequest request,
return;
if ( !mReferer.empty() )
request.headers.emplace( "referer", mReferer.toString() );
std::string cookie = mCookieManager.getCookieHeader( request.uri.getAuthority() );
std::string cookie = getCookieManager().getCookieHeader( request.uri.getAuthority() );
if ( !cookie.empty() )
request.headers["Cookie"] = std::move( cookie );
auto resourceState = mAsyncResourceLoadState;
@@ -933,7 +933,7 @@ void UISceneNode::requestWebResource( WebResourceRequest request,
if ( !scene )
return;
if ( !result.setCookie.empty() )
scene->mCookieManager.storeCookiesFromHeader( authority, result.setCookie );
scene->getCookieManager().storeCookiesFromHeader( authority, result.setCookie );
if ( callback )
callback( result );
};
@@ -947,7 +947,7 @@ TexturePtr UISceneNode::requestWebTexture( WebResourceRequest request,
return {};
if ( !mReferer.empty() )
request.headers.emplace( "referer", mReferer.toString() );
std::string cookie = mCookieManager.getCookieHeader( request.uri.getAuthority() );
std::string cookie = getCookieManager().getCookieHeader( request.uri.getAuthority() );
if ( !cookie.empty() )
request.headers["Cookie"] = std::move( cookie );
auto resourceState = mAsyncResourceLoadState;
@@ -967,7 +967,7 @@ TexturePtr UISceneNode::requestWebTexture( WebResourceRequest request,
if ( !scene )
return;
if ( !result.setCookie.empty() )
scene->mCookieManager.storeCookiesFromHeader( authority, result.setCookie );
scene->getCookieManager().storeCookiesFromHeader( authority, result.setCookie );
if ( callback )
callback( result );
};
+34 -3
View File
@@ -1,8 +1,10 @@
#include <cmath>
#include <eepp/ui/css/propertydefinition.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiscrollbar.hpp>
#include <eepp/ui/uiscrollview.hpp>
#include <eepp/window/input.hpp>
#include <eepp/window/window.hpp>
namespace EE { namespace UI {
@@ -488,6 +490,10 @@ void UIScrollView::setScrollControllerPosition( const Vector2f& position ) {
mVScroll->setValue( maxPosition.y > 0.f ? position.y / maxPosition.y : 0.f );
}
void UIScrollView::setEnableDefaultKeybindings( bool enable ) {
mDefaultKeybindings = enable;
}
bool UIScrollView::isAutoSetClipStep() const {
return mAutoSetClipStep;
}
@@ -505,7 +511,16 @@ void UIScrollView::setAnchorScroll( bool anchor ) {
}
Uint32 UIScrollView::onKeyDown( const KeyEvent& event ) {
if ( !mDefaultKeybindings || event.getSanitizedMod() )
const auto mod = event.getSanitizedMod();
if ( mDefaultKeybindings && event.getKeyCode() == Window::KEY_SPACE &&
( mod & ~KEYMOD_SHIFT ) == 0 ) {
// Active text input gets priority through onTextInput's normal bubbling path.
if ( getUISceneNode()->getWindow()->isTextInputActive() )
return UITouchDraggableWidget::onKeyDown( event );
scrollByViewport( mod & KEYMOD_SHIFT ? -1.f : 1.f );
return 1;
}
if ( !mDefaultKeybindings || mod )
return UITouchDraggableWidget::onKeyDown( event );
if ( event.getKeyCode() == Window::KEY_UP ) {
@@ -515,10 +530,10 @@ Uint32 UIScrollView::onKeyDown( const KeyEvent& event ) {
mVScroll->setValue( mVScroll->getValue() + mVScroll->getClickStep() );
return 1;
} else if ( event.getKeyCode() == Window::KEY_PAGEDOWN ) {
mVScroll->setValue( mVScroll->getValue() + mVScroll->getPageStep() );
scrollByViewport( 1.f );
return 1;
} else if ( event.getKeyCode() == Window::KEY_PAGEUP ) {
mVScroll->setValue( mVScroll->getValue() - mVScroll->getPageStep() );
scrollByViewport( -1.f );
return 1;
} else if ( event.getKeyCode() == Window::KEY_HOME ) {
mVScroll->setValue( mVScroll->getMinValue() );
@@ -531,4 +546,20 @@ Uint32 UIScrollView::onKeyDown( const KeyEvent& event ) {
return UITouchDraggableWidget::onKeyDown( event );
}
Uint32 UIScrollView::onTextInput( const TextInputEvent& event ) {
if ( mDefaultKeybindings && event.getChar() == ' ' && event.isValid( getInput() ) ) {
scrollByViewport( getInput()->isShiftPressed() ? -1.f : 1.f );
return 1;
}
return UITouchDraggableWidget::onTextInput( event );
}
void UIScrollView::scrollByViewport( Float direction ) {
const Float range = getScrollControllerMaxPosition().y;
if ( mVScroll->isEnabled() && range > 0.f ) {
mVScroll->setValue( mVScroll->getValue() +
direction * mContainer->getPixelsSize().getHeight() / range );
}
}
}} // namespace EE::UI
+5
View File
@@ -406,6 +406,11 @@ void UITextInput::onFontStyleChanged() {
invalidateDraw();
}
void UITextInput::onFontColorChanged() {
if ( mPassCache )
mPassCache->setFillColor( mTextCache.getFillColor() );
}
Text& UITextInput::getVisibleTextCache() {
if ( mMode == TextInputMode::Password && mPassCache )
return *mPassCache;
+1 -1
View File
@@ -810,7 +810,7 @@ bool UITextSelectionController::showContextMenu( const Vector2i& position, Uint3
}
auto* selectAllItem = menu->add( mHost->i18n( "uicodeeditor_select_all", "Select All" ) );
selectAllItem->setId( "select-all" );
ContextMenuEvent event( mHost, menu, Event::OnCreateContextMenu, position, flags );
ContextMenuEvent event( mHost, menu, Event::OnCreateContextMenu, position, flags, target );
mHost->sendEvent( &event );
if ( menu->getCount() == 0 ) {
menu->close();
+8 -2
View File
@@ -13,6 +13,7 @@
#include <eepp/ui/uitextspan.hpp>
#include <eepp/ui/uithememanager.hpp>
#include <eepp/ui/uiwidgetcreator.hpp>
#include <eepp/window/input.hpp>
#define PUGIXML_HEADER_ONLY
#include <pugixml/pugixml.hpp>
@@ -765,8 +766,13 @@ Uint32 UIAnchorSpan::onMessage( const NodeMessage* Msg ) {
if ( controller->consumeSuppressedClick() )
return 1;
}
if ( !mHref.empty() && ( Msg->getFlags() & EE_BUTTON_LMASK ) )
getUISceneNode()->openURL( mHref );
if ( !mHref.empty() && ( Msg->getFlags() & ( EE_BUTTON_LMASK | EE_BUTTON_MMASK ) ) ) {
NavigationRequest request{ URI( mHref ) };
request.source = this;
request.mouseButtons = Msg->getFlags();
request.modifiers = getInput() ? getInput()->getModState() : 0;
getUISceneNode()->navigate( request );
}
return 1;
}
}
+4 -1
View File
@@ -306,6 +306,7 @@ UITextView* UITextView::setFontColor( const Color& color ) {
mFontStyleConfig.FontColor = color;
Color newColor( color.r, color.g, color.b, color.a * mAlpha / 255.f );
mTextCache.setFillColor( newColor );
onFontColorChanged();
invalidateDraw();
}
@@ -527,6 +528,8 @@ void UITextView::onFontStyleChanged() {
invalidateDraw();
}
void UITextView::onFontColorChanged() {}
void UITextView::onAlphaChange() {
Color color( getFontColor() );
Color newColor( color.r, color.g, color.b, color.a * mAlpha / 255.f );
@@ -649,7 +652,7 @@ void UITextView::drawSelection( Text& textCache ) {
mSelRectsCache.clear();
mLastSelCurInit = selCurInit();
mLastSelCurEnd = selCurEnd();
mSelRectsCache = mTextCache.getSelectionRects( { selCurInit(), selCurEnd() } );
mSelRectsCache = textCache.getSelectionRects( { selCurInit(), selCurEnd() } );
}
if ( !mSelRectsCache.empty() ) {
+86 -17
View File
@@ -10,6 +10,10 @@
#include <eepp/ui/uiscrollbar.hpp>
#include <eepp/ui/uistyle.hpp>
#include <eepp/ui/uiwebview.hpp>
#include <eepp/window/keycodes.hpp>
#define PUGIXML_HEADER_ONLY
#include <pugixml/pugixml.hpp>
namespace EE { namespace UI {
@@ -168,6 +172,30 @@ UIWebView::UIWebView() : UIScrollView( "webview" ) {
mDocumentLayout->setParent( this );
mDocumentScene = UISceneNode::New();
std::weak_ptr<NavigationLoadState> loadState( mNavigationLoadState );
mDocumentScene->setNavigationInterceptorCb( [loadState]( const NavigationRequest& request ) {
auto locked = loadState.lock();
if ( !locked || !locked->alive || locked->owner == nullptr )
return true;
UIWebView* self = locked->owner;
UISceneNode* docScene = self->getDocumentSceneNode();
if ( !docScene )
return true;
URI uri = docScene->solveRelativePath( request.uri );
const bool requestNewTab = request.target == NavigationRequest::Target::NewTab ||
( request.mouseButtons & EE_BUTTON_MMASK ) ||
( ( request.mouseButtons & EE_BUTTON_LMASK ) &&
( request.modifiers & KeyMod::getDefaultModifier() ) );
if ( requestNewTab ) {
LinkOpenEvent event( self, uri );
self->sendEvent( &event );
if ( event.handled )
return true;
}
self->loadURI( uri, request.method != "GET", request.method, request.body,
request.extraHeaders );
return true;
} );
mDocumentScene->setFollowParentSize( false );
mDocumentScene->setVisibleBoundsNode( mContainer );
mDocumentScene->setParent( mDocumentLayout );
@@ -290,6 +318,7 @@ void UIWebView::loadURI( URI uri, bool isHistoryNav, const std::string& method,
const std::string& body, const Http::Request::FieldTable& headers ) {
Uint64 generation = beginNavigationLoad();
mIsLoading = true;
mTitle.clear();
if ( !isHistoryNav )
pushHistory( uri );
@@ -415,32 +444,66 @@ void UIWebView::loadDocumentData( URI url, std::string data, Uint64 generation )
ui->getStyleSheet().removeAllWithoutMarker( self->mStyleSheetDefaultMarker );
ui->setURIFromURL( url );
std::string documentXML = Tools::HTMLFormatter::HTMLtoXML( data );
pugi::xml_document metadataDocument;
if ( metadataDocument.load_string( documentXML.c_str() ) ) {
self->mTitle = String::trim( std::string_view(
metadataDocument.child( "html" ).child( "head" ).child( "title" ).child_value() ) );
} else {
self->mTitle.clear();
}
auto hash = String::hash( url.toString() );
ui->loadLayoutFromString( Tools::HTMLFormatter::HTMLtoXML( data ), self->mDocContainer,
hash );
ui->loadLayoutFromString( documentXML, self->mDocContainer, hash );
self->mTextSelectionController.onDocumentChanged();
ui->setNavigationInterceptorCb( [loadState]( const NavigationRequest& request ) {
auto locked = loadState.lock();
if ( !locked || !locked->alive || locked->owner == nullptr )
return true;
UIWebView* self = locked->owner;
UISceneNode* docScene = self->getDocumentSceneNode();
if ( !docScene )
return true;
URI uri = docScene->solveRelativePath( request.uri );
self->loadURI( uri, request.method != "GET", request.method, request.body,
request.extraHeaders );
return true;
} );
if ( !self->isNavigationLoadCurrent( generation ) )
return;
self->mIsLoading = false;
NavigationEvent ev( self, (Uint32)Event::OnNavigationCompleted, url, true );
self->sendEvent( &ev );
TitleEvent titleEvent( self, self->mTitle );
self->sendEvent( &titleEvent );
self->markDocumentExtentDirty( LayoutInvalidation::Document );
self->updateDocumentMetricsIfNeeded();
FaviconEvent cleared( self );
self->sendEvent( &cleared );
if ( !self->isNavigationLoadCurrent( generation ) )
return;
for ( auto link : metadataDocument.child( "html" ).child( "head" ).children( "link" ) ) {
std::string_view tokens( link.attribute( "rel" ).value() );
bool isIcon = false;
while ( !tokens.empty() ) {
const size_t start = tokens.find_first_not_of( " \t\r\n\f" );
if ( start == std::string_view::npos )
break;
tokens.remove_prefix( start );
const size_t end = tokens.find_first_of( " \t\r\n\f" );
if ( String::iequals( tokens.substr( 0, end ), "icon" ) ) {
isIcon = true;
break;
}
if ( end == std::string_view::npos )
break;
tokens.remove_prefix( end );
}
const char* href = link.attribute( "href" ).value();
if ( !isIcon || !*href )
continue;
WebResourceRequest request;
request.uri = ui->solveRelativePath( URI( href ) );
request.kind = WebResourceKind::Image;
request.timeout = self->mDefaultTimeout;
ui->requestWebTexture( std::move( request ),
[loadState, generation]( const WebResourceResult& result ) {
auto* view = resolveNavigationLoad( loadState, generation );
if ( !view || !result.success || !result.texture )
return;
FaviconEvent iconEvent( view, result.texture );
view->sendEvent( &iconEvent );
} );
break;
}
} );
}
@@ -532,6 +595,12 @@ UIWebView* UIWebView::setWebResourceCache( WebResourceCachePtr cache, CacheParti
return this;
}
UIWebView* UIWebView::setCookieManager( std::shared_ptr<CookieManager> manager ) {
if ( mDocumentScene )
mDocumentScene->setCookieManager( std::move( manager ) );
return this;
}
bool UIWebView::updateDocumentViewportMetrics() {
if ( !mDocumentScene || !mDocumentLayout || !mDocContainer || mUpdatingDocumentViewportMetrics )
return false;
@@ -671,7 +740,7 @@ Uint32 UIWebView::onNavigationError( std::function<void( const URI&, const std::
Uint32 UIWebView::onTitleChanged( std::function<void( const std::string& )> cb ) {
return on( Event::OnTitleChanged,
[cb]( const Event* e ) { cb( static_cast<const NavigationEvent*>( e )->error ); } );
[cb]( const Event* e ) { cb( static_cast<const TitleEvent*>( e )->title ); } );
}
}} // namespace EE::UI
+4 -4
View File
@@ -33,10 +33,10 @@ std::string makeRequestKey( CachePartitionId partition, const WebResourceRequest
canonicalURI( request.uri ).c_str() );
for ( const auto& header : request.headers ) {
std::string name = String::toLower( header.first );
// The partition identifies the cookie/authentication context. Cookie values evolve while a
// document is being used, and Referer changes with navigation history; including either one
// would duplicate an otherwise reusable resource on Back/Forward navigation.
if ( name == "cookie" || name == "referer" )
// Documents can change with authentication, while static resources remain reusable as
// cookies evolve. Referer changes with navigation history but does not identify content.
if ( ( name == "cookie" && request.kind != WebResourceKind::Document ) ||
name == "referer" )
continue;
key += name;
key += ':';
+201 -28
View File
@@ -1,9 +1,38 @@
#include <eepp/ee.hpp>
#include <eepp/graphics/texturedrawable.hpp>
#include <args/args.hxx>
#include <iostream>
#include <unordered_map>
using namespace EE::UI::Tools;
struct BrowserTabs : UITabWidgetSplitter::Client {
UITabWidgetSplitter* splitter{ nullptr };
UITextInput* urlBar{ nullptr };
UIPushButton* backBtn{ nullptr };
UIPushButton* fwdBtn{ nullptr };
UIWebView* current() const {
return splitter && splitter->getCurWidget() ? splitter->getCurWidget()->asType<UIWebView>()
: nullptr;
}
void updateNavigation() const {
UIWebView* view = current();
backBtn->setEnabled( view && view->canGoBack() );
fwdBtn->setEnabled( view && view->canGoForward() );
}
void onTabCreated( UITab*, UIWidget* ) override {}
void onWidgetFocusChange( UIWidget* widget ) override {
auto* view = widget->asType<UIWebView>();
urlBar->setText( view->getCurrentURI().toString() );
updateNavigation();
}
};
EE_MAIN_FUNC int main( int argc, char** argv ) {
std::shared_ptr<ThreadPool> threadPool(
ThreadPool::createShared( eemax<int>( 4, Sys::getCPUCount() ) ) );
@@ -103,6 +132,8 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
auto vbox = ui->loadLayoutFromString( R"xml(
<style>
#tabs_host TabWidget { max-tab-width: 200dp; }
#tabs_host Tab > Tab::Text { text-overflow: ellipsis; }
PushButton.webview_ui {
border-top-color: transparent;
border-right-color: transparent;
@@ -127,33 +158,157 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
<PushButton lw="26dp" id="refreshbtn" class="webview_ui" text="@string(refresh, Refresh)"
icon="icon(refresh, 18dp)"
text-as-fallback="true" />
<PushButton lw="26dp" id="newtabbtn" class="webview_ui" text="New Tab"
icon="icon(add, 18dp)" text-as-fallback="true" />
<TextInput id="url_bar" layout_width="0" layout_weight="1"
hint="@string(enter_address, Enter Address)" />
</hbox>
<WebView id="webview" layout_width="match_parent" layout_height="0" layout_weight="1" />
<vbox id="tabs_host" layout_width="match_parent" layout_height="0" layout_weight="1" />
</vbox>
)xml",
nullptr, app.getStyleSheetDefaultMarker() );
UIWebView* webView = vbox->find( "webview" )->asType<UIWebView>();
webView->setStyleSheetDefaultMarker( app.getStyleSheetDefaultMarker() );
auto urlBar = ui->find( "url_bar" )->asType<UITextInput>();
auto backBtn = ui->find( "backbtn" )->asType<UIPushButton>();
auto fwdBtn = ui->find( "fwdbtn" )->asType<UIPushButton>();
auto refreshBtn = ui->find( "refreshbtn" )->asType<UIPushButton>();
auto newTabBtn = ui->find( "newtabbtn" )->asType<UIPushButton>();
auto updateNavButtons = [webView, backBtn, fwdBtn]() {
backBtn->setEnabled( webView->canGoBack() );
fwdBtn->setEnabled( webView->canGoForward() );
const std::string configPath = Sys::getConfigPath( "eepp-ui-html" );
const std::string cookiePath =
configPath.empty() ? std::string() : configPath + FileSystem::getOSSlash() + "cookies.txt";
auto cookies = std::make_shared<CookieManager>();
if ( !cookiePath.empty() )
cookies->loadFromFile( cookiePath );
auto saveCookies = [&]() {
if ( !cookiePath.empty() &&
( FileSystem::isDirectory( configPath ) ||
FileSystem::makeDir( configPath, true, 0700 ) ) &&
!cookies->saveToFile( cookiePath ) )
Log::error( "Could not save UI HTML cookies to %s", cookiePath.c_str() );
};
auto cache = WebResourceCache::New();
const auto cachePartition = cache->createPartition();
BrowserTabs browser;
browser.urlBar = urlBar;
browser.backBtn = backBtn;
browser.fwdBtn = fwdBtn;
std::unique_ptr<UITabWidgetSplitter> splitter( UITabWidgetSplitter::New( &browser, ui ) );
splitter->setShowTabBarWhenSplit( true );
browser.splitter = splitter.get();
splitter->setOnTabWidgetCreateCb( [&browser, &splitter]( UITabWidget* tabWidget ) {
tabWidget->on( Event::OnTabClosed, [&browser, &splitter]( const Event* ) {
if ( !browser.current() )
splitter->setCurrentWidget( splitter->getSomeWidget() );
if ( !browser.current() ) {
browser.urlBar->setText( "" );
browser.updateNavigation();
}
} );
} );
splitter->createTabWidget( vbox->find( "tabs_host" ) );
std::function<void( UIWebView* )> configureNavigation;
auto newTab = [&]( bool focus = true ) {
auto* view = UIWebView::New();
view->setStyleSheetDefaultMarker( app.getStyleSheetDefaultMarker() );
view->setCookieManager( cookies );
view->setWebResourceCache( cache, cachePartition );
view->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
auto* target = browser.current() ? splitter->tabWidgetFromWidget( browser.current() )
: splitter->getFirstTabWidget();
splitter->createWidgetInTabWidget( target, view, "New Tab", focus );
view->onNavigationStarted( [&browser, view]( const URI& uri ) {
if ( browser.current() == view )
browser.urlBar->setText( uri.toString() );
} );
view->on( Event::OnCreateContextMenu, [view, cookies, cache, ui,
&saveCookies]( const Event* event ) {
auto* context = static_cast<const ContextMenuEvent*>( event );
auto* menu = context->getMenu();
std::string linkHref;
for ( const Node* node = context->getTarget(); node; node = node->getParent() ) {
if ( node->isType( UI_TYPE_TEXTSPAN ) ) {
auto* span = node->asConstType<UITextSpan>();
if ( span->getElementTag() == "a" ) {
auto* link = static_cast<const UIAnchorSpan*>( span );
if ( !link->getHref().empty() )
linkHref = view->getDocumentSceneNode()
->solveRelativePath( URI( link->getHref() ) )
.toString();
break;
}
}
if ( node == view->getDocumentContainer() )
break;
}
if ( !linkHref.empty() )
menu->add( "Open Link in New Tab" )->setId( "open-link-new-tab" );
menu->addSeparator();
auto* clearDomain = menu->add( "Clear Domain Cookies" );
clearDomain->setId( "clear-domain-cookies" );
clearDomain->setEnabled( !view->getCurrentURI().getAuthority().empty() );
menu->add( "Clear All Cookies" )->setId( "clear-all-cookies" );
menu->add( "Inspect" )->setId( "inspect" );
menu->on( Event::OnItemClicked,
[view, cookies, cache, ui, &saveCookies,
linkHref = std::move( linkHref )]( const Event* itemEvent ) {
const auto& id = itemEvent->getNode()->getId();
if ( id == "open-link-new-tab" ) {
NavigationRequest request{ URI( linkHref ) };
request.target = NavigationRequest::Target::NewTab;
view->getDocumentSceneNode()->navigate( request );
} else if ( id == "clear-domain-cookies" ) {
cookies->clearDomain( view->getCurrentURI().getAuthority() );
cache->clear();
saveCookies();
view->refresh();
} else if ( id == "clear-all-cookies" ) {
cookies->clear();
cache->clear();
saveCookies();
view->refresh();
} else if ( id == "inspect" ) {
UIWidgetInspector::create( ui );
}
} );
} );
configureNavigation( view );
return view;
};
webView->onNavigationStarted(
[urlBar]( const URI& uri ) { urlBar->setText( uri.toString() ); } );
webView->onNavigationCompleted(
[webView, updateNavButtons, urlBar, useHNDark]( const URI& uri ) {
updateNavButtons();
urlBar->setText( uri.toString() );
configureNavigation = [useHNDark, &browser, &splitter, &newTab]( UIWebView* webView ) {
webView->on( Event::OnLinkOpenRequested, [&newTab]( const Event* event ) {
auto* request = static_cast<const UIWebView::LinkOpenEvent*>( event );
auto* view = newTab( false );
view->loadURI( request->uri );
request->accept();
} );
webView->on( Event::OnFaviconChanged, [webView, &splitter]( const Event* event ) {
if ( auto* tab = splitter->getTabFromWidget( webView ) ) {
const auto& icon = static_cast<const UIWebView::FaviconEvent*>( event )->icon;
tab->setIcon( icon ? TextureDrawable::New( icon ) : DrawablePtr{} );
if ( icon )
tab->getIcon()->setSize( 16, 16 );
}
} );
webView->on( Event::OnTitleChanged, [webView, &splitter]( const Event* event ) {
const auto& title = static_cast<const UIWebView::TitleEvent*>( event )->title;
if ( auto* tab = splitter->getTabFromWidget( webView ) ) {
const URI& uri = webView->getCurrentURI();
tab->setText( title.empty() ? ( uri.getAuthority().empty() ? uri.toString()
: uri.getAuthority() )
: title );
}
} );
webView->onNavigationCompleted( [webView, &browser, &splitter,
useHNDark]( const URI& uri ) {
if ( auto* tab = splitter->getTabFromWidget( webView ) )
tab->setText( uri.getAuthority().empty() ? uri.toString() : uri.getAuthority() );
if ( browser.current() == webView ) {
browser.updateNavigation();
browser.urlBar->setText( uri.toString() );
}
if ( useHNDark && uri.getAuthority() == "news.ycombinator.com" ) {
static const std::string_view HN_DARK = R"css(
@@ -192,35 +347,50 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
webView->getDocumentSceneNode()->combineStyleSheet( parser.getStyleSheet() );
}
} );
};
backBtn->onClick( [webView, updateNavButtons]( const MouseEvent* ) {
webView->goHistoryBack();
updateNavButtons();
backBtn->onClick( [&browser]( const MouseEvent* ) {
if ( auto* view = browser.current() ) {
view->goHistoryBack();
browser.updateNavigation();
}
} );
fwdBtn->onClick( [webView, updateNavButtons]( const MouseEvent* ) {
webView->goHistoryForward();
updateNavButtons();
fwdBtn->onClick( [&browser]( const MouseEvent* ) {
if ( auto* view = browser.current() ) {
view->goHistoryForward();
browser.updateNavigation();
}
} );
refreshBtn->onClick( [webView]( const MouseEvent* ) { webView->refresh(); } );
refreshBtn->onClick( [&browser]( const MouseEvent* ) {
if ( auto* view = browser.current() )
view->refresh();
} );
updateNavButtons();
newTabBtn->onClick( [&newTab]( const MouseEvent* ) { newTab(); } );
urlBar->on( Event::OnPressEnter,
[webView, urlBar]( auto ) { webView->loadURI( urlBar->getText().toUtf8() ); } );
urlBar->on( Event::OnPressEnter, [&browser, urlBar]( auto ) {
if ( auto* view = browser.current() )
view->loadURI( urlBar->getText().toUtf8() );
} );
webView->loadURI( !url.Get().empty() ? url.Get() : "https://news.ycombinator.com" );
auto* firstView = newTab();
firstView->loadURI( !url.Get().empty() ? url.Get() : "https://news.ycombinator.com" );
browser.updateNavigation();
win->getInput()->pushCallback( [webView]( InputEvent* event ) {
win->getInput()->pushCallback( [&browser]( InputEvent* event ) {
UIWebView* view = browser.current();
if ( !view )
return;
switch ( event->Type ) {
case InputEvent::FileDropped: {
std::string file( event->file.file );
webView->loadURI( "file://" + file );
view->loadURI( "file://" + file );
break;
}
case InputEvent::TextDropped: {
webView->loadURI( event->textdrop.text );
view->loadURI( event->textdrop.text );
break;
}
default:
@@ -247,7 +417,10 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
TextHints::AllAscii );
app.getWindow()->display();
} );
saveCookies();
return EXIT_SUCCESS;
}
return app.run();
int result = app.run();
saveCookies();
return result;
}
@@ -1,6 +1,8 @@
#include "utest.h"
#include <eepp/network/cookiemanager.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/sys.hpp>
using namespace EE;
using namespace EE::Network;
@@ -74,3 +76,30 @@ UTEST( CookieManager, storeFromRawHeader ) {
cm.storeCookiesFromHeader( "example.com", "key=value; Path=/" );
EXPECT_TRUE( cm.getCookieHeader( "example.com" ) == "key=value" );
}
UTEST( CookieManager, clearDomain ) {
CookieManager cm;
cm.storeCookiesFromHeader( "one.example", "a=1" );
cm.storeCookiesFromHeader( "two.example", "b=2" );
cm.clearDomain( "one.example" );
EXPECT_FALSE( cm.hasCookie( "one.example" ) );
EXPECT_TRUE( cm.hasCookie( "two.example" ) );
}
UTEST( CookieManager, persistence ) {
const std::string path = Sys::getTempPath() + "eepp_cookies_test.txt";
CookieManager saved;
saved.storeCookiesFromHeader( "one.example", "name=value with spaces" );
saved.storeCookiesFromHeader( "two.example", "other=\"quoted\"" );
ASSERT_TRUE( saved.saveToFile( path ) );
CookieManager loaded;
ASSERT_TRUE( loaded.loadFromFile( path ) );
EXPECT_TRUE( loaded.getCookieHeader( "one.example" ) == "name=value with spaces" );
EXPECT_TRUE( loaded.getCookieHeader( "two.example" ) == "other=\"quoted\"" );
saved.storeCookiesFromHeader( "one.example", "name=replaced" );
ASSERT_TRUE( saved.saveToFile( path ) );
ASSERT_TRUE( loaded.loadFromFile( path ) );
EXPECT_TRUE( loaded.getCookieHeader( "one.example" ) == "name=replaced" );
EXPECT_FALSE( FileSystem::fileExists( path + ".tmp" ) );
FileSystem::fileRemove( path );
}
+45
View File
@@ -115,6 +115,51 @@ UTEST( Http, responseHeaderLineLargerThanReceiveBuffer ) {
EXPECT_TRUE( response.getBody() == "hello" );
}
UTEST( Http, redirectSendsCookiePairWithoutSetCookieAttributes ) {
TcpListener listener;
ASSERT_EQ( listener.listen( Socket::AnyPort, IpAddress::LocalHost ), Socket::Done );
std::string redirectedRequest;
std::atomic<bool> serverOk{ false };
std::thread server( [&] {
for ( int step = 0; step < 2; ++step ) {
TcpSocket client;
if ( listener.accept( client ) != Socket::Done )
return;
std::string request;
char buffer[1024];
std::size_t received = 0;
while ( request.find( "\r\n\r\n" ) == std::string::npos ) {
if ( client.receive( buffer, sizeof( buffer ), received ) != Socket::Done )
return;
request.append( buffer, received );
}
if ( step == 1 )
redirectedRequest = std::move( request );
const std::string response = step == 0
? "HTTP/1.1 303 See Other\r\nLocation: /landing\r\n"
"Set-Cookie: session=new; Path=/; HttpOnly\r\n"
"Content-Length: 0\r\nConnection: close\r\n\r\n"
: "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n"
"Connection: close\r\n\r\nok";
if ( client.send( response.data(), response.size() ) != Socket::Done )
return;
client.disconnect();
}
serverOk = true;
} );
Http http( "127.0.0.1", listener.getLocalPort() );
Http::Request request( "/login" );
request.setField( "Cookie", "session=old; pref=1" );
Http::Response response = http.sendRequest( request, Seconds( 5 ) );
server.join();
listener.close();
EXPECT_TRUE( serverOk );
EXPECT_EQ( response.getStatus(), Http::Response::Ok );
EXPECT_TRUE( redirectedRequest.find( "GET /landing " ) == 0 );
EXPECT_TRUE( redirectedRequest.find( "cookie: session=new; pref=1\r\n" ) != std::string::npos );
EXPECT_TRUE( redirectedRequest.find( "HttpOnly" ) == std::string::npos );
}
UTEST( Http, failedTlsHandshakesReleaseConnectionState ) {
if ( !SSL::SSLSocket::isSupported() )
return;
+136
View File
@@ -6,9 +6,11 @@
#include <eepp/graphics/image.hpp>
#include <eepp/graphics/renderer/renderer.hpp>
#include <eepp/graphics/resourcescope.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/graphics/texturedrawable.hpp>
#include <eepp/graphics/texturefactory.hpp>
#include <eepp/graphics/textureregion.hpp>
#include <eepp/scene/eventdispatcher.hpp>
#include <eepp/scene/keyevent.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/filesystem.hpp>
@@ -3622,6 +3624,78 @@ UTEST( UIHTML, WebViewContextMenuCopiesLinkUsingDocumentURI ) {
Engine::destroySingleton();
}
UTEST( UIHTML, WebViewOpensResolvedLinkInNewTab ) {
init_ui_test();
auto* scene = SceneManager::instance()->getUISceneNode();
auto* webView = UIWebView::New();
webView->setParent( scene->getRoot() );
auto* document = webView->getDocumentSceneNode();
document->setURIFromURL( URI( "https://docs.example.com/guide/index.html" ) );
document->loadLayoutFromString(
HTMLFormatter::HTMLtoXML(
R"html(<html><body><a id="link" href="../api/item">link</a></body></html>)html" ),
webView->getDocumentContainer() );
webView->getTextSelectionController()->onDocumentChanged();
scene->update( Seconds( 1 ) );
auto* anchor = document->find<UIAnchorSpan>( "link" );
ASSERT_TRUE( anchor != nullptr );
std::vector<std::string> opened;
webView->on( Event::OnLinkOpenRequested, [&]( const Event* event ) {
auto* request = static_cast<const UIWebView::LinkOpenEvent*>( event );
opened.push_back( request->uri.toString() );
request->accept();
} );
NodeMessage middleClick( anchor, NodeMessage::MouseClick, EE_BUTTON_MMASK );
anchor->messagePost( &middleClick );
ASSERT_EQ( opened.size(), 1u );
EXPECT_STDSTREQ( opened.back(), "https://docs.example.com/api/item" );
NavigationRequest modifiedClick{ URI( anchor->getHref() ) };
modifiedClick.source = anchor;
modifiedClick.mouseButtons = EE_BUTTON_LMASK;
modifiedClick.modifiers = KeyMod::getDefaultModifier();
document->navigate( modifiedClick );
ASSERT_EQ( opened.size(), 2u );
EXPECT_STDSTREQ( opened.back(), "https://docs.example.com/api/item" );
UIPopUpMenu* menu = nullptr;
webView->on( Event::OnCreateContextMenu, [&]( const Event* event ) {
auto* context = static_cast<const ContextMenuEvent*>( event );
menu = context->getMenu();
if ( context->getTarget() == anchor ) {
menu->add( "Open Link in New Tab" )->setId( "open-link-new-tab" );
menu->on( Event::OnItemClicked, [document, anchor]( const Event* itemEvent ) {
if ( itemEvent->getNode()->getId() == "open-link-new-tab" ) {
NavigationRequest request{ URI( anchor->getHref() ) };
request.target = NavigationRequest::Target::NewTab;
document->navigate( request );
}
} );
}
} );
NodeMessage rightUp( anchor, NodeMessage::MouseUp, EE_BUTTON_RMASK );
anchor->messagePost( &rightUp );
ASSERT_TRUE( menu != nullptr );
auto* open = menu->getItemId( "open-link-new-tab" );
ASSERT_TRUE( open != nullptr );
open->activate();
ASSERT_EQ( opened.size(), 3u );
EXPECT_STDSTREQ( opened.back(), "https://docs.example.com/api/item" );
Engine::destroySingleton();
}
UTEST( UIHTML, PasswordTextUsesUpdatedFontColor ) {
struct PasswordInputProbe : UITextInput {
PasswordInputProbe() : UITextInput() {}
Color visibleColor() { return getVisibleTextCache().getFillColor(); }
};
init_ui_test();
auto* input = eeNew( PasswordInputProbe, () );
input->setParent( SceneManager::instance()->getUISceneNode()->getRoot() );
input->setMode( UITextInput::TextInputMode::Password );
input->setFontColor( Color( 17, 31, 47 ) );
EXPECT_TRUE( input->visibleColor() == Color( 17, 31, 47 ) );
Engine::destroySingleton();
}
UTEST( UIHTML, MarkdownContextMenuOmitsLinkForSelection ) {
init_ui_test();
auto* sceneNode = SceneManager::instance()->getUISceneNode();
@@ -7141,3 +7215,65 @@ UTEST( UIHTML, ImageHeightAutoMaxWidthStyleStateTransition ) {
Engine::destroySingleton();
}
UTEST( UIHTML, MarkdownSpaceScrollingUsesParentViewport ) {
init_ui_test();
auto* scene = SceneManager::instance()->getUISceneNode();
auto* scroll = UIScrollView::New();
scroll->setParent( scene->getRoot() );
scroll->setPixelsSize( 400, 300 );
scroll->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed );
auto* markdown = UIMarkdownView::New();
markdown->setParent( scroll->getContainer() );
std::string text;
for ( int i = 0; i < 60; ++i )
text += "A paragraph of Markdown content.\n\n";
markdown->loadFromString( text );
for ( int i = 0; i < 10; ++i )
scene->update( Seconds( 1.f / 60.f ) );
auto* bar = scroll->getVerticalScrollBar();
const Float viewport = scroll->getContainer()->getPixelsSize().getHeight();
const Float range = markdown->getPixelsSize().getHeight() - viewport;
ASSERT_TRUE( range > 2.f * viewport );
scene->getEventDispatcher()->setFocusNode( markdown );
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, 0 );
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, KEYMOD_LSHIFT );
EXPECT_EQ( 0.f, bar->getValue() );
scene->getWindow()->startTextInput();
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, 0 );
EXPECT_EQ( 0.f, bar->getValue() );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
scroll->setEnableDefaultKeybindings( false );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
scene->getWindow()->stopTextInput();
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, 0 );
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
Engine::destroySingleton();
}
UTEST( UIHTML, PasswordSelectionUsesVisibleGlyphWidth ) {
struct PasswordSelectionProbe : UITextInput {
Float maskedWidth() { return getVisibleTextCache().getTextWidth(); }
Float selectedWidth() {
selCurInit( 0 );
selCurEnd( getText().size() );
drawSelection( getVisibleTextCache() );
return mSelRectsCache.empty() ? 0.f : mSelRectsCache.front().getSize().getWidth();
}
};
static_assert( sizeof( PasswordSelectionProbe ) == sizeof( UITextInput ) );
init_ui_test();
auto* input = eeNew( PasswordSelectionProbe, () );
input->setParent( SceneManager::instance()->getUISceneNode()->getRoot() );
input->setText( "WWWWWWWW" );
const Float unmaskedWidth = input->getTextCache()->getTextWidth();
input->setMode( UITextInput::TextInputMode::Password );
const Float maskedWidth = input->maskedWidth();
ASSERT_TRUE( std::abs( unmaskedWidth - maskedWidth ) > 1.f );
EXPECT_NEAR( maskedWidth, input->selectedWidth(), 1.f );
Engine::destroySingleton();
}
+24
View File
@@ -130,6 +130,30 @@ UTEST( UIHTMLForm, submitPOST ) {
Engine::destroySingleton();
}
UTEST( UIHTMLForm, emptyActionSubmitsToCurrentDocumentURL ) {
auto* sceneNode = initFormTest( "Form Empty Action Test" );
sceneNode->setURIFromURL( URI( "https://news.ycombinator.com/login?goto=news" ) );
NavigationRequest intercepted;
sceneNode->setNavigationInterceptorCb( [&]( const NavigationRequest& request ) {
intercepted = request;
return true;
} );
auto* form = UIHTMLForm::New();
form->setParent( sceneNode->getRoot() );
form->setMethod( "post" );
auto* account = UIHTMLInput::New();
account->setParent( form );
account->setInputType( "text" );
account->setStyleSheetInlineProperty( "name", "acct" );
static_cast<UITextInput*>( account->getChildWidget() )->setText( "example" );
form->submit();
EXPECT_TRUE( intercepted.uri.toString() == "https://news.ycombinator.com/login?goto=news" );
EXPECT_TRUE( intercepted.method == "POST" );
EXPECT_TRUE( intercepted.body.find( "acct=example" ) != std::string::npos );
EXPECT_TRUE( intercepted.source == form );
Engine::destroySingleton();
}
UTEST( UIHTMLForm, getValueCheckbox ) {
auto* sceneNode = initFormTest( "Form Checkbox Value" );
@@ -333,6 +333,36 @@ UTEST( UIInspector, InputActionErrorsAndKeyEvents ) {
{ "params", { { "selector", "#first" } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( clicks, 1 );
int rightClicks = 0;
first->on( Event::MouseUp, [&]( const Event* event ) {
if ( event->asMouseEvent()->getFlags() & EE_BUTTON_RMASK )
++rightClicks;
} );
client.send( { { "id", id++ },
{ "method", "input.click" },
{ "params", { { "selector", "#first" }, { "button", "right" } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( rightClicks, 1 );
EXPECT_EQ( clicks, 1 );
int middleClicks = 0;
first->on( Event::MouseClick, [&]( const Event* event ) {
if ( event->asMouseEvent()->getFlags() & EE_BUTTON_MMASK )
++middleClicks;
} );
scene->getEventDispatcher()->setFocusNode( second );
client.send( { { "id", id++ },
{ "method", "input.click" },
{ "params", { { "selector", "#first" }, { "button", "middle" } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( middleClicks, 1 );
EXPECT_EQ( clicks, 1 );
EXPECT_TRUE( scene->getEventDispatcher()->getFocusNode() == second );
client.send( { { "id", id++ },
{ "method", "input.click" },
{ "params", { { "selector", "#first" }, { "button", "other" } } } } );
json invalidButton = client.receive();
ASSERT_TRUE( invalidButton.contains( "error" ) );
EXPECT_TRUE( invalidButton["error"]["code"] == "invalid-params" );
scene->getEventDispatcher()->setFocusNode( first );
int keyDown = 0;
@@ -891,3 +891,27 @@ UTEST( UIWindow, ModalWindowStopsKeyBindingsFromReachingScene ) {
Engine::destroySingleton();
}
UTEST( UISceneNode, CookieJarIsPrivateByDefaultAndReplaceable ) {
UIApplication app(
WindowSettings{ 320, 240, "Shared cookie jars" },
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f ) );
auto* first = app.getUI();
auto* second = UISceneNode::New();
ASSERT_TRUE( &first->getCookieManager() != &second->getCookieManager() );
first->getCookieManager().storeCookiesFromHeader( "example.com", "private=value" );
EXPECT_TRUE( second->getCookieManager().getCookieHeader( "example.com" ).empty() );
auto shared = std::make_shared<CookieManager>();
first->setCookieManager( shared );
second->setCookieManager( shared );
EXPECT_TRUE( &first->getCookieManager() == shared.get() );
EXPECT_TRUE( &second->getCookieManager() == shared.get() );
first->getCookieManager().storeCookiesFromHeader( "example.com", "shared=value" );
EXPECT_TRUE( second->getCookieManager().getCookieHeader( "example.com" ) == "shared=value" );
first->setCookieManager( nullptr );
EXPECT_TRUE( &first->getCookieManager() != shared.get() );
EXPECT_TRUE( first->getCookieManager().getCookieHeader( "example.com" ).empty() );
EXPECT_TRUE( second->getCookieManager().getCookieHeader( "example.com" ) == "shared=value" );
eeDelete( second );
}
@@ -648,6 +648,50 @@ UTEST( UITabWidgetSplitter, ClosingSplitNotifiesBeforeTabWidgetDestruction ) {
eeDelete( splitter );
}
UTEST( UITabWidgetSplitter, ShowsEveryTabBarWhileSplitWhenEnabled ) {
UIApplication app(
WindowSettings( 800, 600, "eepp - unit tests" ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
TestClient client;
auto* splitter = UITabWidgetSplitter::New( &client, app.getUI() );
auto* container = UILayout::New();
container->setParent( app.getUI() );
auto* firstTabs = splitter->createTabWidget( container );
auto* firstWidget = UIWidget::New();
splitter->createWidgetInTabWidget( firstTabs, firstWidget, "First" );
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
auto* secondTabs = splitter->splitTabWidget( SplitDirection::Right, firstTabs );
ASSERT_TRUE( secondTabs != nullptr );
EXPECT_FALSE( splitter->getShowTabBarWhenSplit() );
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
EXPECT_FALSE( secondTabs->getTabBar()->isVisible() );
splitter->setShowTabBarWhenSplit( true );
EXPECT_TRUE( splitter->getShowTabBarWhenSplit() );
EXPECT_TRUE( firstTabs->getTabBar()->isVisible() );
EXPECT_TRUE( secondTabs->getTabBar()->isVisible() );
splitter->setShowTabBarWhenSplit( false );
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
EXPECT_FALSE( secondTabs->getTabBar()->isVisible() );
splitter->setShowTabBarWhenSplit( true );
splitter->setHideTabBar( true );
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
EXPECT_FALSE( secondTabs->getTabBar()->isVisible() );
splitter->setHideTabBar( false );
EXPECT_TRUE( firstTabs->getTabBar()->isVisible() );
EXPECT_TRUE( secondTabs->getTabBar()->isVisible() );
auto* secondWidget = UIWidget::New();
splitter->createWidgetInTabWidget( secondTabs, secondWidget, "Second" );
splitter->closeTab( secondWidget, UITabWidget::FocusTabBehavior::Default );
EXPECT_EQ( splitter->getTabWidgets().size(), 1UL );
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
auto* newSplit = splitter->splitTabWidget( SplitDirection::Bottom, firstTabs );
ASSERT_TRUE( newSplit != nullptr );
EXPECT_TRUE( firstTabs->getTabBar()->isVisible() );
EXPECT_TRUE( newSplit->getTabBar()->isVisible() );
eeDelete( splitter );
}
UTEST( UITabWidgetSplitter, Serialization ) {
UIApplication app(
WindowSettings( 800, 600, "eepp - unit tests" ),
+282
View File
@@ -15,6 +15,7 @@
#include <eepp/network/http.hpp>
#include <eepp/network/tcplistener.hpp>
#include <eepp/network/tcpsocket.hpp>
#include <eepp/scene/eventdispatcher.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/clock.hpp>
#include <eepp/system/filesystem.hpp>
@@ -32,6 +33,8 @@
#include <eepp/ui/uiroot.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiscrollbar.hpp>
#include <eepp/ui/uitextedit.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/ui/uitextspan.hpp>
#include <eepp/ui/uithememanager.hpp>
#include <eepp/ui/uiwebview.hpp>
@@ -54,6 +57,36 @@ class CountingDrawWidget : public UIWidget {
int drawCount{ 0 };
};
// A custom text consumer with no editor type identity or key-down handler.
class TextConsumerWidget : public UIWidget {
public:
TextConsumerWidget() : UIWidget( "custom-text-consumer" ) {}
Uint32 onFocus( NodeFocusReason reason ) override {
UIWidget::onFocus( reason );
getUISceneNode()->getWindow()->startTextInput();
return 1;
}
Uint32 onFocusLoss() override {
getUISceneNode()->getWindow()->stopTextInput();
return UIWidget::onFocusLoss();
}
Uint32 onTextInput( const TextInputEvent& ) override {
if ( consumesText ) {
++acceptedText;
return 1;
}
return 0;
}
Uint32 acceptedText{ 0 };
bool consumesText{ true };
};
static_assert( sizeof( TextConsumerWidget ) <= sizeof( UIWidget ) + 8 );
static bool readHttpRequestHeaders( TcpSocket& client, std::string* headers = nullptr ) {
std::string request;
char buffer[1024];
@@ -69,6 +102,36 @@ static bool readHttpRequestHeaders( TcpSocket& client, std::string* headers = nu
return true;
}
UTEST( UIWebView, EmitsParsedDocumentTitle ) {
auto* window = Engine::instance()->createWindow(
WindowSettings( 320, 240, "UIWebView Title Test", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
auto* font = FontTrueType::New( "NotoSans-Regular" ).get();
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
FontFamily::loadFromRegular( font );
auto* scene = UISceneNode::New();
SceneManager::instance()->add( scene );
scene->getUIThemeManager()->setDefaultFont( font );
auto* view = UIWebView::New();
view->setParent( scene->getRoot() );
std::string title;
view->onTitleChanged( [&]( const std::string& value ) { title = value; } );
const std::string path = Sys::getTempPath() + "eepp_uiwebview_title.html";
FileSystem::fileWrite(
path, "<html><head><title>Useful &amp; Clear</title></head><body>text</body></html>" );
view->loadURI( URI( "file://" + path ) );
for ( int i = 0; i < 10 && title.empty(); ++i ) {
window->getInput()->update();
SceneManager::instance()->update( Seconds( 1.f / 60.f ) );
}
EXPECT_TRUE( title == "Useful & Clear" );
EXPECT_TRUE( view->getTitle() == title );
FileSystem::fileRemove( path );
Engine::destroySingleton();
}
UTEST( UIWebView, DocumentSceneInheritsHostFontRenderingPolicy ) {
auto win = Engine::instance()->createWindow(
WindowSettings( 320, 240, "UIWebView Font Policy Test", WindowStyle::Default,
@@ -3436,3 +3499,222 @@ UTEST( UIWebView, RepeatedRemoteNavigationHandlesSubresourceFanOut ) {
Engine::destroySingleton();
}
UTEST( UIWebView, FaviconUsesRelativeURLAndIgnoresStaleCompletion ) {
auto win = Engine::instance()->createWindow(
WindowSettings( 800, 600, "UIWebView Cache Lease Boundary Test", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ).get();
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
ASSERT_TRUE( font != nullptr && font->loaded() );
FontFamily::loadFromRegular( font );
UISceneNode* sceneNode = UISceneNode::New();
SceneManager::instance()->add( sceneNode );
sceneNode->getUIThemeManager()->setDefaultFont( font );
UIWebView* webView = UIWebView::New();
webView->setParent( sceneNode->getRoot() );
webView->setPixelsSize( 300, 200 );
webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed );
auto cache = WebResourceCache::New();
std::vector<WebResourceCache::FetchCompletion> icons;
std::string requestedIcon;
cache->setFetcher(
[&]( const WebResourceRequest& request, WebResourceCache::FetchCompletion completion ) {
if ( request.kind == WebResourceKind::Image ) {
requestedIcon = request.uri.toString();
icons.emplace_back( std::move( completion ) );
} else {
auto status = Http::Response::Status::Ok;
Http::Response::FieldTable fields;
completion( Http::Response::createFakeResponse(
fields, status,
request.uri.getPath() == "/blank"
? "<html><body>blank</body></html>"
: "<html><head><link rel='SHORTCUT icon' "
"href='y18.svg'></head><body>icon</body></html>" ) );
}
} );
webView->setWebResourceCache( cache );
int loadedIcons = 0;
int clearedIcons = 0;
webView->on( Event::OnFaviconChanged, [&]( const Event* event ) {
const auto& icon = static_cast<const UIWebView::FaviconEvent*>( event )->icon;
if ( icon ) {
++loadedIcons;
EXPECT_EQ( 18u, icon->getImageWidth() );
} else {
++clearedIcons;
}
} );
auto pump = [&] {
for ( int i = 0; i < 10; ++i ) {
win->getInput()->update();
SceneManager::instance()->update( Seconds( 1.f / 60.f ) );
}
};
auto completeIcon = [&] {
ASSERT_FALSE( icons.empty() );
auto completion = std::move( icons.front() );
icons.erase( icons.begin() );
auto status = Http::Response::Status::Ok;
Http::Response::FieldTable fields;
completion( Http::Response::createFakeResponse(
fields, status,
"<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18'><rect width='18' "
"height='18' fill='orange'/></svg>" ) );
pump();
};
webView->loadURI( URI( "https://favicon.example/sub/page" ) );
pump();
EXPECT_STREQ( "https://favicon.example/sub/y18.svg", requestedIcon.c_str() );
completeIcon();
EXPECT_EQ( 1, loadedIcons );
webView->loadURI( URI( "https://favicon.example/other/page" ) );
pump();
webView->loadURI( URI( "https://favicon.example/blank" ) );
pump();
completeIcon();
EXPECT_EQ( 1, loadedIcons );
EXPECT_EQ( 3, clearedIcons );
cache.reset();
Engine::destroySingleton();
}
UTEST( UIWebView, DefaultShortcutsScrollByViewportAndRespectFocusedControls ) {
auto* window = Engine::instance()->createWindow(
WindowSettings( 400, 300, "UIWebView scrolling shortcuts", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
auto* font = FontTrueType::New( "NotoSans-Regular" ).get();
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
FontFamily::loadFromRegular( font );
auto* scene = UISceneNode::New();
SceneManager::instance()->add( scene );
scene->getUIThemeManager()->setDefaultFont( font );
auto* view = UIWebView::New();
view->setParent( scene->getRoot() );
view->setPixelsSize( 400, 300 );
view->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed );
const std::string path = Sys::getTempPath() + "eepp_uiwebview_scroll_keys.html";
FileSystem::fileWrite(
path, "<html><body style='margin:0'><input id='entry'><details><summary "
"id='summary'>Toggle</summary>"
"<p>details</p></details><div style='height:3000px'>tall</div></body></html>" );
view->loadURI( URI( "file://" + path ) );
for ( int i = 0; i < 10; ++i ) {
window->getInput()->update();
SceneManager::instance()->update( Seconds( 1.f / 60.f ) );
}
auto* document = view->getDocumentSceneNode();
const Float viewport = document->getViewportPixelsSize().getHeight();
auto* scroll = view->getVerticalScrollBar();
const Float range = document->getPixelsSize().getHeight() - viewport;
ASSERT_TRUE( range > viewport * 3.f );
auto press = [&]( Keycode key, Uint32 mod = 0 ) {
scene->getEventDispatcher()->sendKeyDown( key, SCANCODE_UNKNOWN, 0, mod );
};
scene->getEventDispatcher()->setFocusNode( view );
press( KEY_SPACE );
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
press( KEY_PAGEDOWN );
EXPECT_NEAR( 2.f * viewport, scroll->getValue() * range, 1.f );
press( KEY_PAGEUP );
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
press( KEY_SPACE, KEYMOD_LSHIFT );
EXPECT_NEAR( 0.f, scroll->getValue(), 0.001f );
press( KEY_PAGEUP );
EXPECT_EQ( 0.f, scroll->getValue() );
scroll->setValue( 1.f - viewport / ( 2.f * range ) );
press( KEY_PAGEDOWN );
EXPECT_EQ( 1.f, scroll->getValue() );
press( KEY_PAGEUP );
EXPECT_NEAR( range - viewport, scroll->getValue() * range, 1.f );
view->setEnableDefaultKeybindings( false );
EXPECT_FALSE( view->areDefaultKeybindingsEnabled() );
const Float disabledValue = scroll->getValue();
press( KEY_SPACE );
press( KEY_PAGEUP );
press( KEY_PAGEDOWN );
press( KEY_HOME );
EXPECT_EQ( disabledValue, scroll->getValue() );
view->setEnableDefaultKeybindings( true );
press( KEY_SPACE, KEYMOD_CTRL );
EXPECT_EQ( disabledValue, scroll->getValue() );
Node* editor = document->find( "entry" );
ASSERT_TRUE( editor != nullptr );
for ( auto* child = editor->getFirstChild(); child; child = child->getNextNode() ) {
if ( child->isType( UI_TYPE_TEXTINPUT ) ) {
editor = child;
break;
}
}
ASSERT_TRUE( editor->isType( UI_TYPE_TEXTINPUT ) );
scene->getEventDispatcher()->setFocusNode( editor );
window->getInput()->beginInputFrame();
press( KEY_SPACE );
EXPECT_EQ( disabledValue, scroll->getValue() );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_TRUE( editor->asType<UITextInput>()->getText() == " " );
EXPECT_EQ( disabledValue, scroll->getValue() );
auto* multiline = UITextEdit::New();
multiline->setParent( document->getRoot() );
scene->getEventDispatcher()->setFocusNode( multiline );
window->getInput()->beginInputFrame();
press( KEY_SPACE );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_TRUE( multiline->getText() == " " );
EXPECT_EQ( disabledValue, scroll->getValue() );
auto* custom = eeNew( TextConsumerWidget, () );
custom->setParent( document->getRoot() );
scene->getEventDispatcher()->setFocusNode( custom );
ASSERT_TRUE( window->isTextInputActive() );
press( KEY_SPACE );
EXPECT_EQ( disabledValue, scroll->getValue() );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_EQ( 1u, custom->acceptedText );
EXPECT_EQ( disabledValue, scroll->getValue() );
custom->consumesText = false;
scroll->setValue( 0.f );
press( KEY_SPACE );
EXPECT_EQ( 0.f, scroll->getValue() );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
InputEvent shiftedSpace( InputEvent::KeyDown );
shiftedSpace.key = {};
shiftedSpace.key.keysym.sym = KEY_SPACE;
shiftedSpace.key.keysym.scancode = SCANCODE_SPACE;
shiftedSpace.key.keysym.mod = KEYMOD_LSHIFT;
window->getInput()->pushEvent( shiftedSpace );
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_EQ( 0.f, scroll->getValue() );
shiftedSpace.Type = InputEvent::KeyUp;
shiftedSpace.key.keysym.mod = 0;
window->getInput()->pushEvent( shiftedSpace );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
view->setEnableDefaultKeybindings( false );
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
view->setEnableDefaultKeybindings( true );
scroll->setValue( disabledValue );
auto* summary = document->find( "summary" );
ASSERT_TRUE( summary != nullptr );
scene->getEventDispatcher()->setFocusNode( summary );
press( KEY_SPACE );
EXPECT_EQ( disabledValue, scroll->getValue() );
FileSystem::fileRemove( path );
Engine::destroySingleton();
}
@@ -198,6 +198,26 @@ UTEST( WebResourceCache, navigationHeadersDoNotDuplicateRetainedEntries ) {
EXPECT_EQ( 1u, cache->getEntryCount() );
}
UTEST( WebResourceCache, documentCacheSeparatesCookieSessions ) {
auto cache = WebResourceCache::New();
int fetches = 0;
cache->setFetcher(
[&]( const WebResourceRequest&, WebResourceCache::FetchCompletion completion ) {
++fetches;
auto ok = Http::Response::Status::Ok;
completion( response( ok, "document" ) );
} );
auto session = cache->createSession();
auto generation = cache->beginNavigation( session, URI( "https://news.example/" ) );
auto request = requestFor( "https://news.example/" );
request.kind = WebResourceKind::Document;
cache->requestData( session, generation, request, {} );
request.headers["Cookie"] = "user=logged-in";
cache->requestData( session, generation, request, {} );
cache->requestData( session, generation, request, {} );
EXPECT_EQ( 2, fetches );
}
UTEST( WebResourceCache, ttlStartsWhenFinalDocumentLeaseIsReleased ) {
auto cache = WebResourceCache::New();
cache->setTTL( Time::Zero );