Skip to content

Commit 865a64a

Browse files
committed
Add custom software cursor
- New AeonGUI::Cursor utility (include/aeongui/Cursor.hpp, core/Cursor.cpp): disabled by default, raster (RasterImage + hotspot) and SVG (URL + render size, hotspot at SVG origin) sources, premultiplied BGRA cache, save/restore backing-store composite that never dirties the document. - Canvas gains GetMutablePixels() (Cairo + Skia) so external compositors can blit directly into the output buffer. - DOM::Window owns a Cursor; mouse handlers do Restore -> update last position -> dispatch -> Composite, and Window::Draw discards the backing store before redrawing then re-blits. - Replaced the legacy core/Cursor.cpp stub and dropped its Doxygen exclusion. - Added tests/CursorTest.cpp (9 cases) covering default state, raster and SVG SetSource, byte-exact composite/restore round-trip, off-screen clipping with sentinels, error handling, and the no-document-dirty guarantee on cursor-only motion.
1 parent 3328a84 commit 865a64a

13 files changed

Lines changed: 818 additions & 34 deletions

File tree

‎cmake/Doxyfile.in‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ RECURSIVE = YES
689689
# Note that relative paths are relative to the directory from which doxygen is
690690
# run.
691691

692-
EXCLUDE = @CMAKE_SOURCE_DIR@/core/Cursor.cpp @CMAKE_SOURCE_DIR@/core/Image.cpp
692+
EXCLUDE = @CMAKE_SOURCE_DIR@/core/Image.cpp
693693

694694
# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or
695695
# directories that are symbolic links (a Unix file system feature) are excluded

‎core/CMakeLists.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ set(AEONGUI_HEADERS
1616
../include/aeongui/DrawType.hpp
1717
../include/aeongui/Color.hpp
1818
../include/aeongui/RasterImage.hpp
19+
../include/aeongui/Cursor.hpp
1920
../include/aeongui/ResourceLoader.hpp
2021
../include/aeongui/StyleSheet.hpp
2122
../include/aeongui/CSSSelector.hpp
@@ -121,6 +122,7 @@ set(AEONGUI_SOURCES
121122
Color.cpp
122123
ResourceLoader.cpp
123124
RasterImage.cpp
125+
Cursor.cpp
124126
StyleSheet.cpp
125127
Canvas.cpp
126128
TextLayout.cpp

‎core/CairoCanvas.cpp‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ namespace AeonGUI
6767
return cairo_image_surface_get_data ( mCairoSurface );
6868
}
6969

70+
uint8_t* CairoCanvas::GetMutablePixels()
71+
{
72+
if ( mCairoSurface )
73+
{
74+
cairo_surface_flush ( mCairoSurface );
75+
uint8_t* data = cairo_image_surface_get_data ( mCairoSurface );
76+
// Mark the surface dirty so Cairo re-uploads any cached image data
77+
// backed by this buffer the next time it is drawn from.
78+
cairo_surface_mark_dirty ( mCairoSurface );
79+
return data;
80+
}
81+
return nullptr;
82+
}
83+
7084
size_t CairoCanvas::GetWidth() const
7185
{
7286
return mCairoSurface ? static_cast<size_t> ( cairo_image_surface_get_width ( mCairoSurface ) ) : 0;

‎core/CairoCanvas.hpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ namespace AeonGUI
4747
AEONGUI_DLL ~CairoCanvas() final;
4848
AEONGUI_DLL void ResizeViewport ( uint32_t aWidth, uint32_t aHeight ) final;
4949
AEONGUI_DLL const uint8_t* GetPixels() const final;
50+
AEONGUI_DLL uint8_t* GetMutablePixels() final;
5051
AEONGUI_DLL size_t GetWidth() const final;
5152
AEONGUI_DLL size_t GetHeight() const final;
5253
AEONGUI_DLL size_t GetStride() const final;

‎core/Cursor.cpp‎

Lines changed: 251 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,277 @@
1-
/******************************************************************************
2-
Copyright 2013 Rodrigo Hernandez Cordoba
1+
/*
2+
Copyright (C) 2026 Rodrigo Jose Hernandez Cordoba
33
4-
Licensed under the Apache License, Version 2.0 (the "License");
5-
you may not use this file except in compliance with the License.
6-
You may obtain a copy of the License at
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
77
8-
http://www.apache.org/licenses/LICENSE-2.0
8+
http://www.apache.org/licenses/LICENSE-2.0
99
10-
Unless required by applicable law or agreed to in writing, software
11-
distributed under the License is distributed on an "AS IS" BASIS,
12-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13-
See the License for the specific language governing permissions and
14-
limitations under the License.
15-
******************************************************************************/
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
1616

17-
#include "Cursor.h"
17+
#include "aeongui/Cursor.hpp"
18+
19+
#include <algorithm>
20+
#include <cstring>
21+
#include <stdexcept>
22+
23+
#include "aeongui/Canvas.hpp"
24+
#include "aeongui/RasterImage.hpp"
25+
#include "aeongui/dom/Document.hpp"
1826

1927
namespace AeonGUI
2028
{
21-
Cursor::Cursor() :
22-
cursor_image ( NULL ),
23-
x ( 0 ),
24-
y ( 0 ),
25-
xoffset ( 0 ),
26-
yoffset ( 0 )
29+
Cursor::Cursor() = default;
30+
Cursor::~Cursor() = default;
31+
32+
void Cursor::Clear()
2733
{
34+
mPixels.clear();
35+
mPixels.shrink_to_fit();
36+
mBacking.clear();
37+
mBacking.shrink_to_fit();
38+
mWidth = 0;
39+
mHeight = 0;
40+
mHotspotX = 0;
41+
mHotspotY = 0;
42+
mSavedX = mSavedY = mSavedW = mSavedH = 0;
43+
mHasBacking = false;
2844
}
29-
Cursor::~Cursor()
45+
46+
void Cursor::SetEnabled ( bool aEnabled )
3047
{
48+
if ( mEnabled == aEnabled )
49+
{
50+
return;
51+
}
52+
mEnabled = aEnabled;
53+
// Drop any pending backing store: it is no longer meaningful and would
54+
// otherwise be written back unexpectedly the next time the cursor is
55+
// re-enabled.
56+
mHasBacking = false;
3157
}
32-
void Cursor::SetCursorImage ( Image* image )
58+
59+
void Cursor::DiscardBackingStore() noexcept
3360
{
34-
cursor_image = image;
61+
mHasBacking = false;
3562
}
36-
Image* Cursor::GetCursorImage()
63+
64+
void Cursor::SetSource ( const RasterImage& aImage,
65+
int32_t aHotspotX, int32_t aHotspotY )
3766
{
38-
return cursor_image;
67+
if ( !aImage.IsLoaded() ||
68+
aImage.GetPixelFormat() != RasterImage::PixelFormat::RGBA8 )
69+
{
70+
throw std::runtime_error ( "Cursor::SetSource: RasterImage is not a loaded RGBA8 image." );
71+
}
72+
const uint32_t w = aImage.GetWidth();
73+
const uint32_t h = aImage.GetHeight();
74+
const size_t srcStride = aImage.GetStride();
75+
const uint8_t* src = aImage.GetPixels();
76+
if ( w == 0 || h == 0 || src == nullptr )
77+
{
78+
throw std::runtime_error ( "Cursor::SetSource: empty RasterImage." );
79+
}
80+
81+
mWidth = w;
82+
mHeight = h;
83+
mHotspotX = aHotspotX;
84+
mHotspotY = aHotspotY;
85+
mPixels.assign ( static_cast<size_t> ( w ) * h * 4u, 0 );
86+
// Convert RGBA8 (straight alpha) -> BGRA8 (premultiplied).
87+
for ( uint32_t y = 0; y < h; ++y )
88+
{
89+
const uint8_t* srcRow = src + y * srcStride;
90+
uint8_t* dstRow = mPixels.data() + static_cast<size_t> ( y ) * w * 4u;
91+
for ( uint32_t x = 0; x < w; ++x )
92+
{
93+
const uint8_t r = srcRow[x * 4u + 0u];
94+
const uint8_t g = srcRow[x * 4u + 1u];
95+
const uint8_t b = srcRow[x * 4u + 2u];
96+
const uint8_t a = srcRow[x * 4u + 3u];
97+
dstRow[x * 4u + 0u] = static_cast<uint8_t> ( ( static_cast<uint32_t> ( b ) * a + 127u ) / 255u );
98+
dstRow[x * 4u + 1u] = static_cast<uint8_t> ( ( static_cast<uint32_t> ( g ) * a + 127u ) / 255u );
99+
dstRow[x * 4u + 2u] = static_cast<uint8_t> ( ( static_cast<uint32_t> ( r ) * a + 127u ) / 255u );
100+
dstRow[x * 4u + 3u] = a;
101+
}
102+
}
103+
// Drop any stale backing store; its rect no longer matches.
104+
mBacking.clear();
105+
mBacking.shrink_to_fit();
106+
mHasBacking = false;
39107
}
40108

41-
void Cursor::SetOffsets ( int32_t xoff, int32_t yoff )
109+
void Cursor::SetSource ( const std::string& aSvgUrl,
110+
uint32_t aWidth, uint32_t aHeight )
42111
{
43-
xoffset = xoff;
44-
yoffset = yoff;
112+
if ( aWidth == 0 || aHeight == 0 )
113+
{
114+
throw std::runtime_error ( "Cursor::SetSource: SVG render size must be > 0." );
115+
}
116+
117+
// Render the SVG into a transient canvas, then snapshot its BGRA pixels.
118+
std::unique_ptr<Canvas> canvas = Canvas::Create ( aWidth, aHeight );
119+
canvas->Clear();
120+
DOM::Document doc;
121+
doc.Load ( aSvgUrl );
122+
doc.Draw ( *canvas );
123+
124+
const uint8_t* pixels = canvas->GetPixels();
125+
const size_t srcStride = canvas->GetStride();
126+
if ( pixels == nullptr )
127+
{
128+
throw std::runtime_error ( "Cursor::SetSource: SVG render produced no pixels." );
129+
}
130+
131+
mWidth = aWidth;
132+
mHeight = aHeight;
133+
// SVG hotspot is fixed at the SVG origin (0, 0) by design.
134+
mHotspotX = 0;
135+
mHotspotY = 0;
136+
mPixels.assign ( static_cast<size_t> ( aWidth ) * aHeight * 4u, 0 );
137+
// Canvas pixels are already premultiplied BGRA — copy row by row in
138+
// case the canvas stride differs from a tightly packed buffer.
139+
const size_t dstStride = static_cast<size_t> ( aWidth ) * 4u;
140+
for ( uint32_t y = 0; y < aHeight; ++y )
141+
{
142+
std::memcpy ( mPixels.data() + y * dstStride,
143+
pixels + y * srcStride,
144+
dstStride );
145+
}
146+
mBacking.clear();
147+
mBacking.shrink_to_fit();
148+
mHasBacking = false;
45149
}
46-
void Cursor::SetPosition ( int32_t xpos, int32_t ypos )
150+
151+
void Cursor::Composite ( uint8_t* aBuffer,
152+
size_t aBufferWidth,
153+
size_t aBufferHeight,
154+
size_t aBufferStride,
155+
int32_t aMouseX,
156+
int32_t aMouseY )
47157
{
48-
x = xpos;
49-
y = ypos;
158+
if ( !mEnabled || !HasSource() || aBuffer == nullptr )
159+
{
160+
return;
161+
}
162+
163+
// Top-left of the cursor image in buffer coordinates.
164+
const int32_t cx = aMouseX - mHotspotX;
165+
const int32_t cy = aMouseY - mHotspotY;
166+
167+
// Clip the cursor rect against the buffer.
168+
const int32_t bw = static_cast<int32_t> ( aBufferWidth );
169+
const int32_t bh = static_cast<int32_t> ( aBufferHeight );
170+
const int32_t x0 = std::max ( cx, 0 );
171+
const int32_t y0 = std::max ( cy, 0 );
172+
const int32_t x1 = std::min ( cx + static_cast<int32_t> ( mWidth ), bw );
173+
const int32_t y1 = std::min ( cy + static_cast<int32_t> ( mHeight ), bh );
174+
if ( x0 >= x1 || y0 >= y1 )
175+
{
176+
// Fully off-screen: no backing store, nothing to blit.
177+
mHasBacking = false;
178+
return;
179+
}
180+
181+
const int32_t w = x1 - x0;
182+
const int32_t h = y1 - y0;
183+
// Offset into the cursor image where the visible region starts.
184+
const int32_t sx = x0 - cx;
185+
const int32_t sy = y0 - cy;
186+
187+
// Snapshot the destination pixels before we overwrite them.
188+
const size_t backingStride = static_cast<size_t> ( w ) * 4u;
189+
mBacking.assign ( backingStride * h, 0 );
190+
for ( int32_t row = 0; row < h; ++row )
191+
{
192+
const uint8_t* dstRow = aBuffer + ( static_cast<size_t> ( y0 + row ) ) * aBufferStride
193+
+ static_cast<size_t> ( x0 ) * 4u;
194+
std::memcpy ( mBacking.data() + static_cast<size_t> ( row ) * backingStride,
195+
dstRow, backingStride );
196+
}
197+
mSavedX = x0;
198+
mSavedY = y0;
199+
mSavedW = w;
200+
mSavedH = h;
201+
mHasBacking = true;
202+
203+
// Alpha-blend cursor (premultiplied src-over) into the buffer.
204+
const size_t cursorStride = static_cast<size_t> ( mWidth ) * 4u;
205+
for ( int32_t row = 0; row < h; ++row )
206+
{
207+
const uint8_t* srcRow = mPixels.data()
208+
+ static_cast<size_t> ( sy + row ) * cursorStride
209+
+ static_cast<size_t> ( sx ) * 4u;
210+
uint8_t* dstRow = aBuffer + ( static_cast<size_t> ( y0 + row ) ) * aBufferStride
211+
+ static_cast<size_t> ( x0 ) * 4u;
212+
for ( int32_t col = 0; col < w; ++col )
213+
{
214+
const uint32_t sb = srcRow[col * 4u + 0u];
215+
const uint32_t sg = srcRow[col * 4u + 1u];
216+
const uint32_t sr = srcRow[col * 4u + 2u];
217+
const uint32_t sa = srcRow[col * 4u + 3u];
218+
if ( sa == 0u )
219+
{
220+
continue;
221+
}
222+
if ( sa == 255u )
223+
{
224+
dstRow[col * 4u + 0u] = static_cast<uint8_t> ( sb );
225+
dstRow[col * 4u + 1u] = static_cast<uint8_t> ( sg );
226+
dstRow[col * 4u + 2u] = static_cast<uint8_t> ( sr );
227+
dstRow[col * 4u + 3u] = 255u;
228+
continue;
229+
}
230+
const uint32_t inv = 255u - sa;
231+
const uint32_t db = dstRow[col * 4u + 0u];
232+
const uint32_t dg = dstRow[col * 4u + 1u];
233+
const uint32_t dr = dstRow[col * 4u + 2u];
234+
const uint32_t da = dstRow[col * 4u + 3u];
235+
dstRow[col * 4u + 0u] = static_cast<uint8_t> ( sb + ( db * inv + 127u ) / 255u );
236+
dstRow[col * 4u + 1u] = static_cast<uint8_t> ( sg + ( dg * inv + 127u ) / 255u );
237+
dstRow[col * 4u + 2u] = static_cast<uint8_t> ( sr + ( dr * inv + 127u ) / 255u );
238+
dstRow[col * 4u + 3u] = static_cast<uint8_t> ( sa + ( da * inv + 127u ) / 255u );
239+
}
240+
}
50241
}
51242

52-
void Cursor::Render ( Renderer* renderer )
243+
void Cursor::Restore ( uint8_t* aBuffer,
244+
size_t aBufferWidth,
245+
size_t aBufferHeight,
246+
size_t aBufferStride )
53247
{
54-
if ( cursor_image != NULL )
248+
if ( !mEnabled || !HasSource() || !mHasBacking || aBuffer == nullptr )
249+
{
250+
return;
251+
}
252+
// Defensive: if the buffer shrank since the snapshot, clamp the
253+
// restore region (do not write out of bounds).
254+
const int32_t bw = static_cast<int32_t> ( aBufferWidth );
255+
const int32_t bh = static_cast<int32_t> ( aBufferHeight );
256+
const int32_t x0 = std::max ( mSavedX, 0 );
257+
const int32_t y0 = std::max ( mSavedY, 0 );
258+
const int32_t x1 = std::min ( mSavedX + mSavedW, bw );
259+
const int32_t y1 = std::min ( mSavedY + mSavedH, bh );
260+
if ( x0 < x1 && y0 < y1 )
55261
{
56-
renderer->DrawImage ( cursor_image, x - xoffset, y - yoffset );
262+
const size_t backingStride = static_cast<size_t> ( mSavedW ) * 4u;
263+
const int32_t srcOffsetX = x0 - mSavedX;
264+
const int32_t srcOffsetY = y0 - mSavedY;
265+
const size_t copyBytes = static_cast<size_t> ( x1 - x0 ) * 4u;
266+
for ( int32_t row = 0; row < ( y1 - y0 ); ++row )
267+
{
268+
std::memcpy ( aBuffer + ( static_cast<size_t> ( y0 + row ) ) * aBufferStride
269+
+ static_cast<size_t> ( x0 ) * 4u,
270+
mBacking.data() + static_cast<size_t> ( srcOffsetY + row ) * backingStride
271+
+ static_cast<size_t> ( srcOffsetX ) * 4u,
272+
copyBytes );
273+
}
57274
}
275+
mHasBacking = false;
58276
}
59277
}

‎core/SkiaCanvas.cpp‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ namespace AeonGUI
9595
return mPixelCache.data();
9696
}
9797

98+
uint8_t* SkiaCanvas::GetMutablePixels()
99+
{
100+
// Ensure the cache reflects the latest Skia surface contents, then
101+
// return it for direct overwriting. The cache stays "clean" until
102+
// the next canvas drawing operation marks it dirty again — at which
103+
// point our writes are discarded (caller must re-blit if needed).
104+
( void ) GetPixels();
105+
return mPixelCache.empty() ? nullptr : mPixelCache.data();
106+
}
107+
98108
size_t SkiaCanvas::GetWidth() const
99109
{
100110
return mWidth;

‎core/SkiaCanvas.hpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ namespace AeonGUI
4646
AEONGUI_DLL ~SkiaCanvas() final;
4747
AEONGUI_DLL void ResizeViewport ( uint32_t aWidth, uint32_t aHeight ) final;
4848
AEONGUI_DLL const uint8_t* GetPixels() const final;
49+
AEONGUI_DLL uint8_t* GetMutablePixels() final;
4950
AEONGUI_DLL size_t GetWidth() const final;
5051
AEONGUI_DLL size_t GetHeight() const final;
5152
AEONGUI_DLL size_t GetStride() const final;

0 commit comments

Comments
 (0)