feat(versionsview): almost done rewriting

This commit is contained in:
neoapps-dev 2026-10-22 17:46:45 +03:00
parent b0f8e3cf86
commit ba17a2a001
14 changed files with 693 additions and 126 deletions

View file

@ -51,28 +51,22 @@
width: 32px;
height: 32px;
}
.gear-btn-open {
background-image: /images/checkbox_highlighted.png;
}
.download-percent {
font-size: 12px;
color: #ffff55;
text-shadow: #000000 1px 1px;
}
.play-btn {
width: 96px;
height: 40px;
font-size: 16px;
color: #ffffff;
border-image: url(/images/Button_Background.png) 13 13 13 13 fill / 13px;
.desc-bg {
border-image: url(/images/helpbg.png) 7 16 16 7 fill / 7px 16px 16px 7px;
border-width: 12px;
}
.play-btn:hover {
border-image: url(/images/button_highlighted.png) 13 13 13 13 fill / 13px;
color: #ffff55;
}
.stop-btn { color: #ff5555; }
.game-error {
color: #ff5555;
font-size: 16px;
text-shadow: #000000 1px 1px;
.desc-label {
font-size: 20px;
color: #ffffff;
text-shadow: #3f3f3f 1px 1px;
}
.backdrop { background: rgba(0, 0, 0, 0.8); }
.modal-frame {
@ -93,20 +87,57 @@
border-image: url(/images/button_highlighted.png) 13 13 13 13 fill / 13px;
color: #ffff55;
}
.log-text {
width: 760px;
height: 360px;
font-size: 14px;
color: #e6f2e6;
.modal-btn-danger { color: #ff5555; }
.modal-btn-danger:hover { color: #ff8080; }
.gear-menu-shell {
background: #4b4b4b;
}
.gear-menu {
background: #1b1b1b;
padding: 6px;
gap: 2px;
}
.gear-menu-title {
font-size: 16px;
color: #ffff55;
text-shadow: #000000 1px 1px;
}
.gear-menu-sep {
width: 100%;
height: 1px;
background: #3a3a3a;
}
.gear-menu-item {
width: 100%;
height: 32px;
gap: 10px;
color: #dddddd;
text-shadow: #000000 1px 1px;
}
.gear-menu-item:hover {
background: #3c3c3c;
color: #ffff55;
}
.gear-menu-item-danger { color: #ff6b6b; }
.gear-menu-item-danger:hover {
background: #4a2222;
color: #ff9d9d;
}
.gear-menu-icon {
width: 20px;
height: 20px;
}
.gear-menu-label {
font-size: 14px;
}
</style>
<template name="EditionRow">
<row class="edition-row {{rowClass}}" onclick="versions.select:{{id}}" align="stretch">
<row id="edition-{{id}}" class="edition-row {{rowClass}}" onclick="versions.select:{{id}}" align="stretch">
<image class="edition-logo" src="{{logo}}" fit="contain" align="center" />
<column gap="4" align="center" flex="1">
<row gap="8" align="start">
<label class="edition-name" text="{{name}}" />
<label if="{{isCustom}}" class="badge-custom" text="{{t:versions.custom}}" />
</row>
</column>
<row gap="8" align="center">
@ -117,8 +148,9 @@
<button if="{{downloading}}" class="action-btn" onclick="versions.cancel:{{id}}">
<image class="action-icon" src="/images/Trash_Bin_Icon.png" />
</button>
<button class="action-btn" onclick="versions.settings:{{id}}">
<image class="action-icon" src="/images/gear.png" />
<button class="action-btn {{gearClass}}" onclick="versions.settings:{{id}}">
<image if="{{updateAvailable}}" class="action-icon" src="/images/Update_Icon.png" />
<image if="{{showGear}}" class="action-icon" src="/images/gear.png" />
</button>
</row>
</row>
@ -128,15 +160,66 @@
<tooltip icon="B">Back</tooltip>
<tooltip icon="X">Install</tooltip>
<column width="1280px" height="720px" align="center" padding="40">
<use template="TitleImage"/>
<column height="40px"/>
<use template="TitleImage"/>
<column height="20px"/>
<column class="options-bg" padding="4" width="1024px" height="372px" align="center">
<scroll class="version-recess" width="100%" height="100%" id="edition-list">
<scroll class="version-recess" width="100%" height="100%" id="edition-list" hide-partial="true">
<column gap="4" width="97.9%" foreach="{{editions}}" as="edition">
<use template="EditionRow" id="{{edition.id}}" name="{{edition.name}}" logo="{{edition.logo}}" notInstalled="{{edition.notInstalled}}" showInstall="{{edition.showInstall}}" showPlay="{{edition.showPlay}}" showStop="{{edition.showStop}}" downloading="{{edition.downloading}}" downloadPercentText="{{edition.downloadPercentText}}" rowClass="{{edition.rowClass}}" />
<use template="EditionRow"
id="{{edition.id}}"
name="{{edition.name}}"
logo="{{edition.logo}}"
isCustom="{{edition.isCustom}}"
notInstalled="{{edition.notInstalled}}"
showInstall="{{edition.showInstall}}"
showGear="{{edition.showGear}}"
updateAvailable="{{edition.updateAvailable}}"
downloading="{{edition.downloading}}"
downloadPercentText="{{edition.downloadPercentText}}"
rowClass="{{edition.rowClass}}"
gearClass="{{edition.gearClass}}" />
</column>
</scroll>
</column>
<column height="8px"/>
<column class="desc-bg" width="1024px" height="60px" align="center">
<label class="desc-label" text="{{selectedDesc}}" />
</column>
<column class="backdrop" if="{{settingsMenu}}" style="position:absolute;left:0px;top:0px;width:1280px;height:720px" onclick="versions.menu.close">
<column class="gear-menu-shell" style="position:absolute;left:841px;top:{{menuTop}}px;width:250px" padding="2px">
<column id="gear-menu" class="gear-menu" gap="2px">
<label class="gear-menu-title" text="{{menuTitle}}" />
<column class="gear-menu-sep" />
<column foreach="{{menuItems}}" as="item" gap="2px">
<row class="gear-menu-item {{item.itemClass}}" onclick="{{item.action}}" gap="10px">
<image class="gear-menu-icon" src="{{item.icon}}" fit="contain" align="center" />
<label class="gear-menu-label" text="{{item.label}}" align="center" />
</row>
</column>
<column class="gear-menu-sep" />
<column foreach="{{menuDangerItems}}" as="item" gap="2px">
<row class="gear-menu-item {{item.itemClass}}" onclick="{{item.action}}" gap="10px">
<image class="gear-menu-icon" src="{{item.icon}}" fit="contain" align="center" />
<label class="gear-menu-label" text="{{item.label}}" align="center" />
</row>
</column>
</column>
</column>
</column>
<column class="backdrop" if="{{deleteModal}}" style="position:absolute;left:0px;top:0px;width:1280px;height:720px">
<column id="delete-modal" class="modal-frame" style="position:absolute;left:440px;top:220px;width:400px" align="center" gap="8">
<label class="modal-title" text="{{deleteTitle}}" />
<label text="{{t:versions.deleteWarning}}" />
<row align="center" gap="20">
<button class="modal-btn" onclick="versions.delete.cancel">
<label text="{{t:common.cancel}}"/>
</button>
<button class="modal-btn modal-btn-danger" onclick="versions.delete.confirm">
<label text="{{t:common.delete}}"/>
</button>
</row>
</column>
</column>
</column>
</view>
</ui>

View file

@ -17,7 +17,8 @@
"unknown": "Unknown",
"all": "All",
"noResults": "No results",
"set": "set"
"set": "set",
"close": "Close"
},
"header": {
"title": "Emerald Legacy Launcher"

View file

@ -17,7 +17,8 @@
"unknown": "Inconnu",
"all": "Tous",
"noResults": "Aucun résultat",
"set": "placer"
"set": "placer",
"close": "Fermer"
},
"header": {
"title": "Lanceur Emerald Legacy"

View file

@ -17,7 +17,8 @@
"unknown": "Неизвестно",
"all": "Все",
"noResults": "Без результатов",
"set": "Поставить"
"set": "Поставить",
"close": "Закрыть"
},
"header": {
"title": "Emerald Legacy Launcher"

View file

@ -6,6 +6,7 @@
#include <archive_entry.h>
#include <string_view>
#include <sys/stat.h>
#include <vector>
namespace emerald::instances {
namespace {
bool safe_path_within(const std::filesystem::path &base,
@ -74,6 +75,29 @@ bool is_workshop_path(const std::filesystem::path &child,
}
return false;
}
bool move_entry(const std::filesystem::path &source,
const std::filesystem::path &target) {
std::error_code error;
std::filesystem::rename(source, target, error);
if (!error) {
return true;
}
error.clear();
if (std::filesystem::is_directory(source, error)) {
error.clear();
if (!core::copy_directory(source, target)) {
return false;
}
} else {
if (!core::copy_file(source, target)) {
return false;
}
}
std::error_code remove_error;
std::filesystem::remove_all(source, remove_error);
return true;
}
} // namespace
bool extract_archive(const std::filesystem::path &archive,
@ -181,23 +205,50 @@ void flatten_instance(const std::filesystem::path &instance_dir) {
if (error) {
return;
}
std::filesystem::path only_child;
std::size_t count = 0;
std::filesystem::path inner_dir;
for (const std::filesystem::directory_entry &entry : it) {
++count;
only_child = entry.path();
const std::filesystem::path &child = entry.path();
if (!std::filesystem::is_directory(child, error)) {
continue;
}
error.clear();
if (!std::filesystem::exists(child / std::string(kExecutableMarker),
error)) {
continue;
}
inner_dir = child;
break;
}
if (count != 1 || !std::filesystem::is_directory(only_child, error)) {
if (inner_dir.empty()) {
return;
}
std::error_code move_error;
std::vector<std::filesystem::path> inner_entries;
for (const std::filesystem::directory_entry &entry :
std::filesystem::directory_iterator(only_child, move_error)) {
const std::filesystem::path target = instance_dir / entry.path().filename();
std::filesystem::rename(entry.path(), target, move_error);
move_error.clear();
std::filesystem::directory_iterator(inner_dir, move_error)) {
inner_entries.push_back(entry.path());
}
remove_all(only_child, move_error);
move_error.clear();
bool complete = true;
for (const std::filesystem::path &source : inner_entries) {
const std::filesystem::path target = instance_dir / source.filename();
if (names_match_keep(source, keep_list()) &&
std::filesystem::exists(target, move_error)) {
move_error.clear();
continue;
}
if (!move_entry(source, target)) {
complete = false;
}
}
if (!complete) {
return;
}
std::error_code remove_error;
std::filesystem::remove_all(inner_dir, remove_error);
}
DownloadStatus install_instance_archive(

View file

@ -22,6 +22,47 @@ void arrange_absolute(const ElementPtr &child, const Rect &container) {
style.height.value_or(child->measured().height)});
}
bool fully_inside(const Rect &bounds, const Rect &view) {
return bounds.y >= view.y - 0.5F &&
bounds.y + bounds.height <= view.y + view.height + 0.5F;
}
bool outside_view(const Rect &bounds, const Rect &view) {
return bounds.y + bounds.height <= view.y + 0.5F ||
bounds.y >= view.y + view.height - 0.5F;
}
bool spans_view(const Rect &bounds, const Rect &view) {
return bounds.y <= view.y + 0.5F &&
bounds.y + bounds.height >= view.y + view.height - 0.5F;
}
void render_visible_children(const Element *parent, RenderContext &context,
const TextStyle &parent_text, const Rect &view) {
const TextStyle child_text =
resolve_text_style(parent->visual_style(), parent_text);
for (const ElementPtr &child : parent->children()) {
if (!child->style().visible) {
continue;
}
if (child->style().absolute) {
child->render(context, child_text);
continue;
}
const Rect &bounds = child->bounds();
if (outside_view(bounds, view)) {
continue;
}
if (fully_inside(bounds, view)) {
child->render(context, child_text);
continue;
}
if (spans_view(bounds, view)) {
render_visible_children(child.get(), context, child_text, view);
}
}
}
void render_box(RenderContext &context, const Element &element) {
const ComputedStyle &style = element.visual_style();
const float alpha = style.opacity;
@ -284,8 +325,8 @@ public:
sizing.letterSpacing = resolved.letterSpacing;
sizing.bold = resolved.bold;
const Size natural = context.text_size(text_, sizing);
line_height_ = resolved.lineHeight > 0.0F ? resolved.lineHeight
: natural.height;
line_height_ =
resolved.lineHeight > 0.0F ? resolved.lineHeight : natural.height;
leading_ = std::max(0.0F, (line_height_ - natural.height) * 0.5F);
text_align_ = resolved.textAlign;
lines_.clear();
@ -332,8 +373,8 @@ public:
} else if (text_align_ == Align::End) {
x = bounds_.x + bounds_.width - line_width;
}
const float y = bounds_.y + line_height_ * static_cast<float>(i) +
leading_;
const float y =
bounds_.y + line_height_ * static_cast<float>(i) + leading_;
context.draw_text(line, Rect{x, y, line_width, line_height_}, effective);
}
}
@ -433,6 +474,7 @@ public:
autoscroll_duration_ = 0.0F;
autoscroll_from_ = 50.0F;
autoscroll_to_ = -200.0F;
hide_partial_ = attribute("hide-partial") == "true";
if (const std::string &auto_scroll = attribute("autoscroll");
!auto_scroll.empty()) {
autoscroll_duration_ = std::atof(auto_scroll.c_str());
@ -515,13 +557,17 @@ public:
}
render_box(context, *this);
const float in = inset();
context.push_clip(Rect{bounds_.x + in, bounds_.y + in,
bounds_.width - in * 2.0F,
bounds_.height - in * 2.0F});
const Rect view{bounds_.x + in, bounds_.y + in, bounds_.width - in * 2.0F,
bounds_.height - in * 2.0F};
context.push_clip(view);
const TextStyle childText = resolve_text_style(visual_style(), text);
for (const ElementPtr &child : children_) {
if (child->style().visible) {
child->render(context, childText);
if (hide_partial_) {
render_visible_children(this, context, childText, view);
} else {
for (const ElementPtr &child : children_) {
if (child->style().visible) {
child->render(context, childText);
}
}
}
context.pop_clip();
@ -538,6 +584,13 @@ public:
arrange(bounds_);
}
bool hides_partial_items() const override { return hide_partial_; }
Rect scroll_viewport() const override {
const float in = inset();
return Rect{bounds_.x + in, bounds_.y + in, bounds_.width - in * 2.0F,
bounds_.height - in * 2.0F};
}
Element *scroll_hit_test(const Vec2 &point) {
return bounds_.contains(point) ? this : nullptr;
}
@ -574,21 +627,20 @@ public:
if (autoscroll_duration_ <= 0.0F) {
return active;
}
const float view_height =
bounds_.height - inset() * 2.0F;
const float view_height = bounds_.height - inset() * 2.0F;
const uint64_t duration_ms =
static_cast<uint64_t>(autoscroll_duration_ * 1000.0F);
const uint64_t elapsed =
now_ticks >= start_ticks_ ? now_ticks - start_ticks_ : 0U;
const float progress = duration_ms > 0U
? std::min(1.0F, static_cast<float>(elapsed) /
static_cast<float>(duration_ms))
: 1.0F;
const float progress =
duration_ms > 0U ? std::min(1.0F, static_cast<float>(elapsed) /
static_cast<float>(duration_ms))
: 1.0F;
const float fraction_percent =
autoscroll_from_ + (autoscroll_to_ - autoscroll_from_) * progress;
const float fraction = fraction_percent * 0.01F;
scroll_offset_ = (content_height_ - view_height) * 0.5F -
fraction * content_height_;
scroll_offset_ =
(content_height_ - view_height) * 0.5F - fraction * content_height_;
return elapsed < duration_ms;
}
@ -599,15 +651,12 @@ private:
float autoscroll_duration_ = 0.0F;
float autoscroll_from_ = 50.0F;
float autoscroll_to_ = -200.0F;
bool hide_partial_ = false;
uint64_t start_ticks_ = 0U;
};
} // namespace
TextInputElement::TextInputElement() {
set_interactive(true);
}
SliderElement::SliderElement() {
set_interactive(true);
}
TextInputElement::TextInputElement() { set_interactive(true); }
SliderElement::SliderElement() { set_interactive(true); }
void TextInputElement::end_edit(bool commit) {
if (!editing_) {
return;
@ -663,9 +712,7 @@ void TextInputElement::measure(const MeasureContext &context,
measured_.width = clamped_width(content_width);
measured_.height = clamped_height(content_height);
}
void TextInputElement::arrange(const Rect &rect) {
bounds_ = rect;
}
void TextInputElement::arrange(const Rect &rect) { bounds_ = rect; }
void TextInputElement::render(RenderContext &context,
const TextStyle &inherited) const {
if (!style_.visible) {
@ -704,7 +751,8 @@ void TextInputElement::render(RenderContext &context,
}
const float line_height = static_cast<float>(resolved.fontSize) + 4.0F;
std::string_view remaining = display;
float line_y = box.y + (box.height - std::min(line_height, box.height)) * 0.5F;
float line_y =
box.y + (box.height - std::min(line_height, box.height)) * 0.5F;
if (multiline()) {
line_y = box.y + box.height - line_height;
}
@ -712,9 +760,9 @@ void TextInputElement::render(RenderContext &context,
const std::size_t nl = remaining.find('\n');
const std::string_view line =
nl == std::string_view::npos ? remaining : remaining.substr(0, nl);
context.draw_text(std::string(line),
Rect{box.x + 8.0F, line_y, box.width - 16.0F, line_height},
text_style);
context.draw_text(
std::string(line),
Rect{box.x + 8.0F, line_y, box.width - 16.0F, line_height}, text_style);
if (nl == std::string_view::npos) {
break;
}
@ -741,16 +789,14 @@ void SliderElement::measure(const MeasureContext &context,
measured_.width = clamped_width(style_.width.value_or(600.0F));
measured_.height = clamped_height(style_.height.value_or(40.0F));
}
void SliderElement::arrange(const Rect &rect) {
bounds_ = rect;
}
void SliderElement::arrange(const Rect &rect) { bounds_ = rect; }
void SliderElement::adjust(int delta) {
value_ = std::clamp(value_ + delta, 0, 100);
}
void SliderElement::click_at(float x) {
const float width = std::max(1.0F, bounds_.width);
value_ = std::clamp(
static_cast<int>((x - bounds_.x) / width * 100.0F), 0, 100);
value_ =
std::clamp(static_cast<int>((x - bounds_.x) / width * 100.0F), 0, 100);
}
void SliderElement::render(RenderContext &context,
const TextStyle &inherited) const {
@ -761,10 +807,10 @@ void SliderElement::render(RenderContext &context,
const float handle_width = 20.0F;
const float fraction =
static_cast<float>(value_) / 100.0F * (bounds_.width - handle_width);
context.draw_image("/images/Slider_Handle.png",
Rect{bounds_.x + fraction, bounds_.y, handle_width,
bounds_.height},
style_.opacity);
context.draw_image(
"/images/Slider_Handle.png",
Rect{bounds_.x + fraction, bounds_.y, handle_width, bounds_.height},
style_.opacity);
const TextStyle resolved = resolve_text_style(visual_style(), inherited);
ComputedStyle label_style = visual_style();
label_style.fontFamily = resolved.fontFamily;
@ -772,12 +818,11 @@ void SliderElement::render(RenderContext &context,
label_style.color = resolved.color;
label_style.textShadow = resolved.textShadow;
if (label_width_ > 0.0F) {
context.draw_text(
attribute("label"),
Rect{bounds_.x + (bounds_.width - label_width_) * 0.5F,
bounds_.y + (bounds_.height - label_height_) * 0.5F, label_width_,
label_height_},
label_style);
context.draw_text(attribute("label"),
Rect{bounds_.x + (bounds_.width - label_width_) * 0.5F,
bounds_.y + (bounds_.height - label_height_) * 0.5F,
label_width_, label_height_},
label_style);
}
}
Element::Element() = default;
@ -864,6 +909,28 @@ Element *Element::scroll_hit_test(const Vec2 &point) {
return nullptr;
}
bool Element::hidden_by_scroll() const {
for (const Element *ancestor = parent_; ancestor != nullptr;
ancestor = ancestor->parent_) {
if (!ancestor->hides_partial_items()) {
continue;
}
const Rect view = ancestor->scroll_viewport();
for (const Element *current = this; current != ancestor;
current = current->parent_) {
const Rect &bounds = current->bounds();
if (outside_view(bounds, view)) {
return true;
}
if (!fully_inside(bounds, view) && !spans_view(bounds, view)) {
return true;
}
}
return false;
}
return false;
}
Element *Element::find_by_id(const std::string &id) {
if (id_ == id) {
return this;

View file

@ -70,6 +70,9 @@ public:
bool has_click_handler() const { return !click_handler_.empty(); }
bool interactive() const { return interactive_; }
void set_interactive(bool value) { interactive_ = value; }
virtual bool hides_partial_items() const { return false; }
virtual Rect scroll_viewport() const { return bounds_; }
bool hidden_by_scroll() const;
void set_hovered(bool h) { hovered_ = h; }
bool is_hovered() const { return hovered_; }
void set_hover_style(ComputedStyle s) { hover_style_ = std::move(s); }

View file

@ -8,6 +8,7 @@
#include "Ui/Views/SettingsView.hpp"
#include "Ui/Views/VersionsView.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
namespace emerald::ui {
namespace {
ControllerType controller_type_from_sdl(SDL_GamepadType type) {
@ -31,7 +32,8 @@ ControllerType controller_type_from_sdl(SDL_GamepadType type) {
}
}
std::string tooltip_set_for(ControllerType type) {
std::string tooltip_set_for(
ControllerType type) { // neo: TODO: update controller tooltip types
switch (type) {
case ControllerType::Orbis:
return "Durango";
@ -106,8 +108,24 @@ void UiSystem::detect_controller_type() {
set_controller_type(detected);
}
void UiSystem::refresh_tooltips() {
void UiSystem::refresh_tooltips(const Json &data) {
std::vector<TooltipHint> hints;
if (data.is_object()) {
const auto found = data.find("tooltips");
if (found != data.end() && found->is_array()) {
for (const Json &entry : *found) {
if (!entry.is_object()) {
continue;
}
TooltipHint hint;
hint.icon = entry.value("icon", "");
hint.label = entry.value("label", "");
hints.push_back(std::move(hint));
}
renderer_.set_tooltips(std::move(hints));
return;
}
}
if (const ViewDefinition *definition = document_.find_view(current_view_)) {
hints.reserve(definition->tooltips.size());
for (const TooltipDefinition &tooltip : definition->tooltips) {
@ -117,6 +135,21 @@ void UiSystem::refresh_tooltips() {
renderer_.set_tooltips(std::move(hints));
}
void UiSystem::focus_element(const std::string &id) {
pending_focus_id_ = id;
pending_focus_ = true;
focus_engaged_ = true;
dirty_ = true;
}
void UiSystem::set_focus_scope(const std::string &id) {
focus_scope_id_ = id;
pending_focus_id_.clear();
pending_focus_ = true;
focus_engaged_ = true;
dirty_ = true;
}
void UiSystem::register_handlers() {
handlers_.bind("go", [this](std::string_view argument) {
navigate(std::string(argument));
@ -200,13 +233,13 @@ void UiSystem::play_sound_for(const std::string &handler) {
void UiSystem::rebuild() {
hover_chain_.clear();
active_text_ = nullptr;
refresh_tooltips();
const auto view = views_.find(current_view_);
if (view == views_.end()) {
return;
}
update_panorama();
const Json data = view->second->data(services_);
refresh_tooltips(data);
DataContext context(data, &i18n_);
ViewBuilder builder(document_, registry_);
tree_ = builder.build(current_view_, context);
@ -222,7 +255,33 @@ void UiSystem::rebuild() {
static_cast<float>(logical_height_)});
tree_->seed_clock(view_entered_ticks_);
focus_order_.clear();
collect_focusables(tree_.get());
Element *scope_root = tree_.get();
if (!focus_scope_id_.empty()) {
Element *scope = tree_->find_by_id(focus_scope_id_);
if (scope == nullptr) {
focus_scope_id_.clear();
} else {
scope_root = scope;
}
}
collect_focusables(scope_root);
if (pending_focus_) {
pending_focus_ = false;
int target = 0;
if (!pending_focus_id_.empty()) {
for (std::size_t index = 0; index < focus_order_.size(); ++index) {
if (focus_order_[index]->id() == pending_focus_id_) {
target = static_cast<int>(index);
break;
}
}
}
pending_focus_id_.clear();
if (!focus_order_.empty()) {
focus_ = std::clamp(target, 0, static_cast<int>(focus_order_.size()) - 1);
focus_engaged_ = true;
}
}
if (focus_ >= static_cast<int>(focus_order_.size())) {
focus_ =
focus_order_.empty() ? 0 : static_cast<int>(focus_order_.size()) - 1;
@ -322,6 +381,19 @@ void UiSystem::activate_focus() {
dispatch(handler);
}
bool UiSystem::handle_back() {
const auto view = views_.find(current_view_);
if (view != views_.end() && view->second->on_escape(services_)) {
dirty_ = true;
return true;
}
if (services_.audio != nullptr) {
services_.audio->play_back();
}
navigate_back();
return true;
}
TextInputElement *UiSystem::active_text_input() {
if (active_text_ == nullptr) {
return nullptr;
@ -457,7 +529,8 @@ Element *UiSystem::find_clickable(Element *element, const Vec2 &point) const {
return found;
}
}
if (element->interactive() && element->bounds().contains(point)) {
if (element->interactive() && element->bounds().contains(point) &&
!element->hidden_by_scroll()) {
return element;
}
return nullptr;
@ -604,17 +677,7 @@ void UiSystem::handle_event(const SDL_Event &event) {
end_text_edit(false);
break;
}
{
const auto view = views_.find(current_view_);
if (view != views_.end() && view->second->on_escape(services_)) {
dirty_ = true;
break;
}
if (services_.audio != nullptr) {
services_.audio->play_back();
}
navigate_back();
}
handle_back();
break;
case SDLK_LEFT:
if (editing) {
@ -707,6 +770,13 @@ void UiSystem::handle_event(const SDL_Event &event) {
}
activate_focus();
break;
case SDL_GAMEPAD_BUTTON_EAST:
if (editing) {
end_text_edit(false);
break;
}
handle_back();
break;
default:
break;
}

View file

@ -35,6 +35,12 @@ public:
focus_ = 0;
dirty_ = true;
};
services_.focus_element = [this](const std::string &id) {
focus_element(id);
};
services_.focus_scope = [this](const std::string &id) {
set_focus_scope(id);
};
services_.read_text_input = [this]() { return text_result_; };
services_.text_edited = [this]() { return text_edited_; };
}
@ -43,6 +49,8 @@ public:
void invalidate() { dirty_ = true; }
const std::string &current_view() const { return current_view_; }
void set_controller_type(ControllerType type);
void focus_element(const std::string &id);
void set_focus_scope(const std::string &id);
std::vector<Element *> hover_chain_;
void handle_event(const SDL_Event &event);
void render();
@ -50,7 +58,7 @@ public:
private:
void register_handlers();
void rebuild();
void refresh_tooltips();
void refresh_tooltips(const Json &data);
void detect_controller_type();
void update_panorama();
void dispatch(const std::string &handler);
@ -61,6 +69,7 @@ private:
void apply_focus();
void move_focus(int direction);
void activate_focus();
bool handle_back();
TextInputElement *active_text_input();
void begin_text_edit(Element *element);
void end_text_edit(bool commit);
@ -90,6 +99,9 @@ private:
std::vector<Element *> focus_order_;
int focus_ = 0;
bool focus_engaged_ = false;
std::string focus_scope_id_;
std::string pending_focus_id_;
bool pending_focus_ = false;
bool gamepad_ = true;
ControllerType controller_type_ = ControllerType::Unknown;
bool dirty_ = true;

View file

@ -28,6 +28,8 @@ struct UiServices {
std::function<void(const std::string &)> navigate;
std::function<void(const std::string &)> change_language;
std::function<void()> reset_focus;
std::function<void(const std::string &)> focus_element;
std::function<void(const std::string &)> focus_scope;
std::function<std::string()> read_text_input;
std::function<bool()> text_edited;
};

View file

@ -1,4 +1,5 @@
#include "Ui/Views/VersionsView.hpp"
#include "Audio/AudioEngine.hpp"
#include "Config/ConfigSchema.hpp"
#include "Core/Fs.hpp"
#include "Core/Log.hpp"
@ -8,13 +9,21 @@
#include "Instances/InstanceManager.hpp"
#include "Launch/Servers.hpp"
#include "Ui/EditionResolver.hpp"
#include "Ui/I18n.hpp"
#include <algorithm>
#include <cstdlib>
#include <utility>
#include <vector>
namespace emerald::ui {
namespace {
constexpr const char *kArgsSchemaFile = "Arguments.Schema.json";
constexpr const char *kGearMenuScope = "gear-menu";
constexpr const char *kDeleteModalScope = "delete-modal";
constexpr const char *kEditionRowPrefix = "edition-";
constexpr int kMenuFirstRowTop = 244;
constexpr int kMenuRowStep = 76;
constexpr int kMenuMinTop = 220;
constexpr int kMenuMaxTop = 380;
bool has_args_schema(const instances::InstanceManager &instances,
const std::string &instance_id) {
const std::optional<std::string> text =
@ -42,6 +51,7 @@ std::string state_name(instances::DownloadState state) {
}
} // namespace
Json VersionsView::data(const UiServices &services) const {
Json data = Json::object();
data["titleImage"] = "/images/MenuTitle.png";
@ -51,6 +61,18 @@ Json VersionsView::data(const UiServices &services) const {
data["status"] = status_;
data["editions"] = Json::array();
data["selected"] = "legacy_evolved";
data["selectedDesc"] = "";
data["deleteModal"] = !delete_pending_id_.empty();
data["deleteTitle"] = "";
data["settingsMenu"] = !open_menu_id_.empty();
data["menuId"] = open_menu_id_;
data["menuTitle"] = "";
data["menuDeleteLabel"] = "";
data["menuTop"] = kMenuFirstRowTop;
Json items = Json::array();
Json danger_items = Json::array();
data["menuItems"] = Json::array();
data["menuDangerItems"] = Json::array();
if (services.config == nullptr) {
return data;
}
@ -71,11 +93,17 @@ Json VersionsView::data(const UiServices &services) const {
}
}
int menu_row_index = -1;
int visible_index = 0;
for (const instances::Edition &edition : editions) {
const bool installed = instance_manager.is_installed(edition.id);
if (edition.hidden && !installed) {
continue;
}
if (edition.id == open_menu_id_) {
menu_row_index = visible_index;
}
++visible_index;
Json entry = Json::object();
entry["id"] = edition.id;
@ -85,15 +113,20 @@ Json VersionsView::data(const UiServices &services) const {
entry["category"] = edition.category;
entry["installed"] = installed;
entry["notInstalled"] = !installed;
entry["isCustom"] = edition.id.rfind("custom_", 0) == 0;
entry["selected"] = edition.id == active_profile;
entry["rowClass"] = edition.id == active_profile ? "selected" : "";
entry["gearClass"] = edition.id == open_menu_id_ ? "gear-btn-open" : "";
entry["managed"] = instance_manager.is_managed(edition.id);
entry["comingSoon"] = edition.coming_soon;
entry["canDownload"] = !edition.url.empty() && !edition.hidden;
entry["showInstall"] = !installed;
entry["showGear"] = true;
entry["updateAvailable"] = false;
entry["hasArgsSchema"] =
installed && has_args_schema(instance_manager, edition.id);
entry["path"] = instance_manager.working_dir(edition.id).string();
if (services.downloads != nullptr) {
const instances::DownloadSnapshot download =
services.downloads->snapshot(edition.id);
@ -128,6 +161,10 @@ Json VersionsView::data(const UiServices &services) const {
entry["playtimeDay"] = 0;
}
if (edition.id == active_profile) {
data["selectedDesc"] = edition.desc;
}
data["editions"].push_back(std::move(entry));
}
@ -138,9 +175,157 @@ Json VersionsView::data(const UiServices &services) const {
}
}
if (!open_menu_id_.empty()) {
const instances::Edition *menu_edition = nullptr;
for (const instances::Edition &ed : editions) {
if (ed.id == open_menu_id_) {
menu_edition = &ed;
break;
}
}
if (menu_edition != nullptr) { // neo: sadly i couldnt do it in xml (because
// im lazy) so i did it in c++ instead
const bool installed = instance_manager.is_installed(open_menu_id_);
const bool is_custom = open_menu_id_.rfind("custom_", 0) == 0;
data["menuTitle"] = menu_edition->name;
if (menu_row_index >= 0) {
const int top = kMenuFirstRowTop + menu_row_index * kMenuRowStep;
data["menuTop"] = std::clamp(top, kMenuMinTop, kMenuMaxTop);
}
const auto translate = [&services](const char *key,
const char *fallback) {
return services.i18n != nullptr ? services.i18n->translate(key)
: std::string(fallback);
};
data["menuDeleteLabel"] =
is_custom ? translate("versions.removeCustom", "Remove Custom")
: translate("versions.uninstall", "Uninstall");
const auto add_item = [&items,
&danger_items](const char *icon, std::string label,
std::string action, bool danger) {
Json item = Json::object();
item["icon"] = icon;
item["label"] = std::move(label);
item["action"] = std::move(action);
item["itemClass"] = danger ? "gear-menu-item-danger" : "";
if (danger) {
danger_items.push_back(std::move(item));
} else {
items.push_back(std::move(item));
}
};
const std::string &id = open_menu_id_;
if (installed) {
add_item("/images/Update_Icon.png",
translate("versions.repair", "Repair"),
"versions.repair:" + id, false);
}
if (installed) {
add_item("/images/Folder_Icon.png",
translate("versions.openFolder", "Open Folder"),
"versions.folder:" + id, false);
}
add_item("/images/steam.png",
translate("versions.addToSteam", "Add to Steam"),
"versions.steam:" + id, false);
if (installed) {
add_item("/images/Download_Icon.png",
translate("versions.importWorld", "Import World"),
"versions.importWorld:" + id, false);
}
if (installed) {
add_item("/images/Layer_Icon.png",
translate("versions.backup", "Backup"),
"versions.backup:" + id, false);
}
add_item("/images/lock_icon.png", translate("versions.setUid", "Set UID"),
"versions.setUid:" + id, false);
if (is_custom) {
add_item("/images/Change_Skin_Icon.png",
translate("versions.editCustom", "Edit Custom"),
"versions.editCustom:" + id, false);
}
add_item("/images/Trash_Bin_Icon.png",
data["menuDeleteLabel"].get<std::string>(),
"versions.delete:" + id, true);
}
}
data["menuItems"] = std::move(items);
data["menuDangerItems"] = std::move(danger_items);
if (!delete_pending_id_.empty()) {
std::string delete_name = delete_pending_id_;
for (const Json &item : data["editions"]) {
if (item.value("id", "") == delete_pending_id_) {
delete_name = item.value("name", delete_pending_id_);
break;
}
}
std::string delete_title_template =
services.i18n != nullptr
? services.i18n->translate("versions.deleteTitle")
: std::string("versions.deleteTitle");
const std::string placeholder = "{{name}}";
const std::size_t pos = delete_title_template.find(placeholder);
if (pos != std::string::npos) {
delete_title_template.replace(pos, placeholder.size(), delete_name);
}
data["deleteTitle"] = std::move(delete_title_template);
}
if (!open_menu_id_.empty() || !delete_pending_id_.empty()) {
Json tooltips = Json::array(); // neo: thats how you do it manually :3
Json select = Json::object();
select["icon"] = "A";
select["label"] = "Select";
Json close = Json::object();
close["icon"] = "B";
close["label"] = services.i18n != nullptr
? services.i18n->translate("common.close")
: std::string("Close");
tooltips.push_back(std::move(select));
tooltips.push_back(std::move(close));
data["tooltips"] = std::move(tooltips);
}
return data;
}
void VersionsView::open_menu(std::string id, UiServices &services) {
open_menu_id_ = std::move(id);
if (services.focus_scope) {
services.focus_scope(kGearMenuScope);
}
}
void VersionsView::close_menu(UiServices &services) {
if (open_menu_id_.empty()) {
return;
}
const std::string id = open_menu_id_;
open_menu_id_.clear();
if (services.focus_scope) {
services.focus_scope("");
}
if (!id.empty() && services.focus_element) {
services.focus_element(std::string(kEditionRowPrefix) + id);
}
}
void VersionsView::close_delete_modal(UiServices &services) {
if (delete_pending_id_.empty()) {
return;
}
const std::string id = delete_pending_id_;
delete_pending_id_.clear();
if (services.focus_scope) {
services.focus_scope("");
}
if (!id.empty() && services.focus_element) {
services.focus_element(std::string(kEditionRowPrefix) + id);
}
}
bool VersionsView::action(std::string_view name, UiServices &services) {
if (services.config == nullptr) {
return false;
@ -152,9 +337,11 @@ bool VersionsView::action(std::string_view name, UiServices &services) {
colon == std::string::npos ? action : action.substr(0, colon);
const std::string argument =
colon == std::string::npos ? std::string() : action.substr(colon + 1);
if (verb == "versions.select") {
services.config->data().profile = argument;
services.config->save();
close_menu(services);
return true;
}
if (verb == "versions.refresh") {
@ -162,8 +349,29 @@ bool VersionsView::action(std::string_view name, UiServices &services) {
return true;
}
if (action == "versions.menu.close") {
close_menu(services);
if (services.audio != nullptr) {
services.audio->play_back();
}
return true;
}
if (verb == "versions.settings") {
if (open_menu_id_ == argument) {
close_menu(services);
} else {
open_menu(argument, services);
if (services.audio != nullptr) {
services.audio->play_press();
}
}
return true;
}
const instances::InstanceManager instance_manager(services.config->data());
if (verb == "versions.install" || verb == "versions.repair") {
close_menu(services);
if (services.downloads == nullptr) {
status_ = "Downloads are not available";
return true;
@ -201,12 +409,14 @@ bool VersionsView::action(std::string_view name, UiServices &services) {
status_.clear();
return true;
}
if (verb == "versions.cancel") {
if (services.downloads != nullptr) {
services.downloads->cancel(argument);
}
return true;
}
if (verb == "versions.dismiss") {
if (services.downloads != nullptr) {
services.downloads->clear(argument);
@ -217,18 +427,72 @@ bool VersionsView::action(std::string_view name, UiServices &services) {
return true;
}
if (verb == "versions.folder" || verb == "versions.steam" ||
verb == "versions.importWorld" || verb == "versions.backup" ||
verb == "versions.setUid" || verb == "versions.editCustom") {
close_menu(services);
return true;
}
if (verb == "versions.delete") {
if (!instance_manager.delete_instance(argument)) {
status_ = "Cannot delete " + argument;
close_menu(services);
delete_pending_id_ = argument;
if (services.focus_scope) {
services.focus_scope(kDeleteModalScope);
}
return true;
}
if (action == "versions.delete.cancel") {
close_delete_modal(services);
if (services.audio != nullptr) {
services.audio->play_back();
}
return true;
}
if (action == "versions.delete.confirm") {
const std::string pending = delete_pending_id_;
close_delete_modal(services);
if (pending.empty()) {
return true;
}
if (services.config->data().profile.value_or("") == argument) {
if (!instance_manager.delete_instance(pending)) {
status_ = "Cannot delete " + pending;
return true;
}
if (services.config->data().profile.value_or("") == pending) {
services.config->data().profile = "legacy_evolved";
services.config->save();
}
status_ = "Deleted " + argument;
status_ = "Deleted " + pending;
return true;
}
return false;
}
bool VersionsView::on_escape(UiServices &services) {
if (!open_menu_id_.empty()) {
close_menu(services);
if (services.audio != nullptr) {
services.audio->play_back();
}
return true;
}
if (!delete_pending_id_.empty()) {
close_delete_modal(services);
if (services.audio != nullptr) {
services.audio->play_back();
}
return true;
}
if (services.audio != nullptr) {
services.audio->play_back();
}
if (services.navigate) {
services.navigate("home");
}
return true;
}
} // namespace emerald::ui

View file

@ -8,10 +8,14 @@ public:
std::string_view id() const override { return "versions"; }
Json data(const UiServices &services) const override;
bool action(std::string_view name, UiServices &services) override;
void set_status(std::string status) { status_ = std::move(status); }
const std::string &status() const { return status_; }
bool on_escape(UiServices &services) override;
private:
void open_menu(std::string id, UiServices &services);
void close_menu(UiServices &services);
void close_delete_modal(UiServices &services);
std::string status_;
std::string delete_pending_id_;
std::string open_menu_id_;
};
} // namespace emerald::ui
} // namespace emerald::ui

View file

@ -277,6 +277,8 @@ ElementPtr ViewBuilder::build_element(const pugi::xml_node &node,
StringMap attributes;
std::vector<std::string> classes;
std::string inline_style;
std::map<std::string, std::string> style_attributes;
for (const pugi::xml_attribute attribute : node.attributes()) {
const std::string name = attribute.name();
if (name == "foreach" || name == "if" || name == "as") {
@ -292,13 +294,18 @@ ElementPtr ViewBuilder::build_element(const pugi::xml_node &node,
classes = split_classes(value);
} else if (name == "onclick") {
element->set_click_handler(value);
} else if (!is_style_attribute(name)) {
attributes[name] = value;
} else if (name == "style") {
inline_style = std::move(value);
} else if (is_style_attribute(name)) {
style_attributes[name] = std::move(value);
} else {
attributes[name] = std::move(value);
}
}
element->set_attributes(std::move(attributes));
element->set_classes(std::move(classes));
apply_styles(*element, node);
apply_styles(*element, node, std::move(inline_style),
std::move(style_attributes));
for (const pugi::xml_node child : node.children()) {
ElementPtr child_element = build_node(child, context, depth + 1);
if (child_element != nullptr) {
@ -343,20 +350,19 @@ ElementPtr ViewBuilder::instantiate_template(const std::string &name,
return result;
}
void ViewBuilder::apply_styles(Element &element,
const pugi::xml_node &node) const {
void ViewBuilder::apply_styles(
Element &element, const pugi::xml_node &node,
const std::string &inline_style,
const std::map<std::string, std::string> &style_attributes) const {
const auto build = [&](bool hovered) {
ComputedStyle style;
style = document_.styles().applyTo(style, node.name(), element.id(),
element.classes(), hovered);
for (const pugi::xml_attribute attribute : node.attributes()) {
const std::string name = attribute.name();
const std::string value = attribute.as_string();
if (name == "style") {
style = ComputedStyle::merge(style, parse_inline_style(value));
} else if (is_style_attribute(name)) {
apply_style_property(style, name, value);
}
if (!inline_style.empty()) {
style = ComputedStyle::merge(style, parse_inline_style(inline_style));
}
for (const auto &[name, value] : style_attributes) {
apply_style_property(style, name, value);
}
return style;
};

View file

@ -72,7 +72,9 @@ private:
const pugi::xml_node &use_node,
DataContext &context,
std::size_t depth) const;
void apply_styles(Element &element, const pugi::xml_node &node) const;
void apply_styles(Element &element, const pugi::xml_node &node,
const std::string &inline_style,
const std::map<std::string, std::string> &style_attributes) const;
const UiDocument &document_;
ElementRegistry &registry_;
};