feat(ui): add ScrollView widget and dynamic layout on resize

Introduces a ScrollView widget with mouse wheel support and viewport clipping.
Credits and screenshot screens now use it and recompute layout on window resize.
Offset storage switched from ivec2 to vec2 with new accessors.
This commit is contained in:
2026-08-07 17:15:40 +08:00
parent 4e5a8d7dea
commit f397143e30
8 changed files with 230 additions and 35 deletions

View File

@@ -108,4 +108,5 @@ target_sources(${PROJECT_NAME}
gameplay/block_manager.cpp
ui/screenshot_ui.cpp
scene/screenshot_scene.cpp
ui/scroll_view.cpp
)

View File

@@ -8,12 +8,19 @@
#include "Cubed/ui/column_layout.hpp"
#include "Cubed/ui/image.hpp"
#include "Cubed/ui/rect.hpp"
#include "Cubed/ui/scroll_view.hpp"
#include <algorithm>
namespace Cubed {
CreditsUI::CreditsUI(CreditsScene& scene) : m_scene(scene) {}
void CreditsUI::init() {
auto& renderer = m_scene.scene_manager().app().renderer();
update_layout(renderer.window_width(), renderer.window_height());
}
void CreditsUI::update_layout(int, int height) {
m_root_widget.reset();
auto image = std::make_unique<Image>(nullptr);
image
->set_image("texture/ui/background.png",
@@ -23,25 +30,38 @@ void CreditsUI::init() {
auto& rect = image->add_child<Rect>();
rect.set_color(Color::BLACK).set_alpha(0.7f).set_fill_parent(true);
auto& title = rect.add_child<Label>();
title.set_color(Color::WHITE)
.set_text(tr("menu.main.credits"))
.set_anchor(Anchor::TOP_CENTER)
.set_offset({0, 10});
auto& return_button = rect.add_child<Button>();
return_button.set_background_image(
"texture/ui/button001.png",
m_scene.scene_manager().app().texture_manager());
return_button.set_text(tr("button.return"));
return_button.set_anchor(Anchor::BOTTOM_CENTER).set_offset({0, -20});
return_button.set_clicked(
[this]() { m_scene.scene_manager().request_pop(); });
{
auto& button = rect.add_child<Button>();
button.set_background_image(
"texture/ui/button001.png",
m_scene.scene_manager().app().texture_manager());
auto& scroll = rect.add_child<ScrollView>();
scroll.set_anchor(Anchor::TOP_CENTER)
.set_offset({0, 20 + title.height()});
button.set_text(tr("button.return"));
button.set_anchor(Anchor::BOTTOM_CENTER).set_offset({0, -20});
button.set_clicked([this]() { m_scene.scene_manager().request_pop(); });
}
{
auto& layout = rect.add_child<ColumnLayout>();
layout.set_spacing(20)
.set_anchor(Anchor::TOP_CENTER)
.set_offset({0, 20});
layout.add_child<Label>().set_text("Cubed");
auto add_text = [&](std::string_view view, float scale = 0.5f) {
layout.add_child<Label>().set_text(view).set_scale(scale);
float scroll_height = height - scroll.get_offset().y -
return_button.height() +
return_button.get_offset().y;
scroll.set_height(std::max(0.0f, scroll_height));
auto layout = std::make_unique<ColumnLayout>(&scroll);
layout->set_spacing(20).set_anchor(Anchor::TOP_CENTER);
layout->add_child<Label>().set_text("Cubed");
auto add_text = [&](std::string_view view, float scale = 0.8f) {
layout->add_child<Label>().set_text(view).set_scale(scale);
};
add_text("A cube game like Minecraft, using C++ and OpenGL.");
add_text("Author: zhenyan121");
@@ -73,6 +93,8 @@ void CreditsUI::init() {
add_text("SkyOnPole");
add_text("free_w_cloud");
add_text("Last but not least, thanks to you");
scroll.set_child(std::move(layout));
}
m_root_widget = std::move(image);
@@ -86,4 +108,9 @@ bool CreditsUI::handle_key_event(const KeyEvent& e) {
return UIManager::handle_key_event(e);
}
bool CreditsUI::handle_window_resize_event(const WindowResizeEvent& e) {
update_layout(e.width, e.height);
return UIManager::handle_window_resize_event(e);
}
} // namespace Cubed

View File

@@ -7,6 +7,7 @@
#include "Cubed/ui/button.hpp"
#include "Cubed/ui/column_layout.hpp"
#include "Cubed/ui/row_layout.hpp"
#include "Cubed/ui/scroll_view.hpp"
#include <algorithm>
#include <array>
@@ -42,10 +43,19 @@ void ScreenshotUI::update_layout(int width, int height) {
int height = 0;
};
auto& return_button = bg->add_child<Button>();
return_button.set_default_image(texture_manager);
return_button.set_anchor(Anchor::BOTTOM_CENTER);
return_button.set_offset({0, -30});
return_button.set_text(tr("button.return"));
return_button.set_clicked(
[this]() { m_scene.scene_manager().request_pop(); });
fs::path path = Renderer::SCREENSHOT_PATH;
std::vector<ImageInfo> image_infos;
constexpr float IMAGE_SCALE = 0.25;
if (fs::exists(path)) {
for (auto& entry : fs::recursive_directory_iterator(path)) {
if (!fs::is_regular_file(entry)) {
@@ -66,18 +76,26 @@ void ScreenshotUI::update_layout(int width, int height) {
constexpr int SPACING = 10;
const int WIDGET_WIDTH = std::min(width, width - 2 * PADDING);
// const int WIDGET_HEIGHT = std::max(height, height - 2 * PADDING);
float cur_width = 0.0f;
// float cur_height = 0.0f;
auto& root_column = bg->add_child<ColumnLayout>();
root_column.set_child_anchor(ColumnLayoutAnchor::LEFT)
.set_spacing(10)
.set_anchor(Anchor::TOP_CENTER)
auto& scroll = bg->add_child<ScrollView>();
scroll.set_anchor(Anchor::TOP_CENTER)
.set_offset({0, 10 + title.height()});
float scroll_height = height - scroll.get_offset().y +
return_button.get_offset().y -
return_button.height();
scroll.set_height(std::max(0.0f, scroll_height));
auto root_column = std::make_unique<ColumnLayout>(&scroll);
root_column->set_child_anchor(ColumnLayoutAnchor::LEFT)
.set_spacing(10)
.set_anchor(Anchor::TOP_CENTER);
RowLayout* row = nullptr;
auto create_row = [&row, &root_column]() {
row = &root_column.add_child<RowLayout>();
row = &root_column->add_child<RowLayout>();
row->set_anchor(Anchor::TOP_LEFT);
row->set_spacing(SPACING);
};
@@ -95,6 +113,7 @@ void ScreenshotUI::update_layout(int width, int height) {
auto& im = row->add_child<Image>();
im.set_texture(image.texture, true).set_scale(IMAGE_SCALE);
}
scroll.set_child(std::move(root_column));
}
if (!fs::exists(path) || image_infos.empty()) {
auto& label = bg->add_child<Label>();
@@ -103,15 +122,6 @@ void ScreenshotUI::update_layout(int width, int height) {
.set_anchor(Anchor::CENTER);
}
{
auto& button = bg->add_child<Button>();
button.set_default_image(texture_manager);
button.set_anchor(Anchor::BOTTOM_CENTER);
button.set_offset({0, -50});
button.set_text(tr("button.return"));
button.set_clicked([this]() { m_scene.scene_manager().request_pop(); });
}
m_root_widget = std::move(bg);
}

112
src/ui/scroll_view.cpp Normal file
View File

@@ -0,0 +1,112 @@
#include "Cubed/ui/scroll_view.hpp"
#include "Cubed/render/renderer.hpp"
#include "Cubed/tools/log.hpp"
#include "Cubed/ui/column_layout.hpp"
#include <algorithm>
#include <glad/glad.h>
namespace Cubed {
ScrollView::ScrollView(Widget* parent) : Widget(parent) {}
ScrollView::~ScrollView() {}
void ScrollView::render(Renderer& renderer) {
if (!m_visible)
return;
auto p = pos();
float h = height();
float w = width();
// convert window coords to framebuffer coords for scissor.
float sx = renderer.window_width() > 0
? renderer.frame_width() / renderer.window_width()
: 1.0f;
float sy = renderer.window_height() > 0
? renderer.frame_height() / renderer.window_height()
: 1.0f;
GLint scissor_x = static_cast<GLint>(p.x * sx);
GLint scissor_y = static_cast<GLint>((m_window_height - p.y - h) * sy);
GLsizei scissor_w = static_cast<GLsizei>(std::max(0.0f, w * sx));
GLsizei scissor_h = static_cast<GLsizei>(std::max(0.0f, h * sy));
glEnable(GL_SCISSOR_TEST);
glScissor(scissor_x, scissor_y, scissor_w, scissor_h);
Widget::render(renderer);
glDisable(GL_SCISSOR_TEST);
}
float ScrollView::width() const {
if (Widget::width() > 0) {
return Widget::width();
}
if (m_child) {
return m_child->width();
}
return 0.0f;
}
float ScrollView::height() const {
if (Widget::height() > 0) {
return Widget::height();
}
Logger::warn("You should set Scroll view height!");
if (m_parent) {
return m_parent->height();
}
return m_window_height;
}
ScrollView& ScrollView::set_child(std::unique_ptr<ColumnLayout> children) {
Widget::children().clear();
m_child = children.get();
Widget::children().emplace_back(std::move(children));
m_child->set_offset({0, 0});
m_child->set_fill_height(false);
m_child->set_fill_parent(false);
m_child->set_fill_width(false);
return *this;
}
bool ScrollView::handle_mouse_move_event(const MouseMoveEvent& e) {
if (Widget::handle_mouse_move_event(e)) {
return true;
}
auto p = pos();
if (e.xpos >= p.x && e.xpos <= p.x + width() && e.ypos >= p.y &&
e.ypos <= p.y + height()) {
m_hovered = true;
return true;
}
m_hovered = false;
return false;
}
bool ScrollView::handle_mouse_wheel_event(const MouseWheelEvent& e) {
if (m_hovered && m_child) {
float c_height = m_child->height();
float offset = m_child->get_offset().y;
float viewport = height();
float max_offset = std::max(0.0f, c_height - viewport);
float new_offset =
std::clamp(offset + e.offset * m_scroll_speed, -max_offset, 0.0f);
m_child->set_offset({0, new_offset});
return true;
}
return Widget::handle_mouse_wheel_event(e);
}
Widget& ScrollView::set_fill_parent(bool) {
Logger::warn("You should not use this function in scroll view!");
return *this;
}
Widget& ScrollView::set_fill_width(bool) {
Logger::warn("You should not use this function in scroll view!");
return *this;
}
Widget& ScrollView::set_fill_height(bool) {
Logger::warn("You should not use this function in scroll view!");
return *this;
}
} // namespace Cubed

View File

@@ -158,10 +158,16 @@ Widget& Widget::set_anchor(Anchor anchor) {
m_anchor = anchor;
return *this;
}
Widget& Widget::set_offset(glm::ivec2 offset) {
Widget& Widget::set_offset(glm::vec2 offset) {
m_offset = offset;
return *this;
}
Widget& Widget::add_offset(glm::vec2 offset) {
m_offset += offset;
return *this;
}
glm::vec2 Widget::get_offset() const { return m_offset; }
void Widget::set_window_size(int width, int height) {
m_window_height = height;
m_window_width = width;