ballistica/src/ballistica/base/ui/dev_console.cc

1524 lines
47 KiB
C++
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Released under the MIT License. See LICENSE for details.
#include "ballistica/base/ui/dev_console.h"
#include "ballistica/base/app_adapter/app_adapter.h"
#include "ballistica/base/app_mode/app_mode.h"
#include "ballistica/base/audio/audio.h"
#include "ballistica/base/graphics/component/simple_component.h"
#include "ballistica/base/graphics/mesh/nine_patch_mesh.h"
#include "ballistica/base/graphics/text/text_graphics.h"
#include "ballistica/base/logic/logic.h"
#include "ballistica/base/platform/base_platform.h"
#include "ballistica/base/python/base_python.h"
#include "ballistica/base/support/repeater.h"
#include "ballistica/base/ui/ui.h"
#include "ballistica/shared/foundation/event_loop.h"
#include "ballistica/shared/generic/utils.h"
#include "ballistica/shared/python/python_command.h"
#include "ballistica/shared/python/python_sys.h"
namespace ballistica::base {
// How much of the screen the console covers when it is at full size.
const float kDevConsoleSize{0.9f};
const int kDevConsoleLineLimit{80};
const int kDevConsoleStringBreakUpSize{1950};
const float kDevConsoleTabButtonCornerRadius{16.0f};
const double kTransitionSeconds{0.15};
enum class DevConsoleHAnchor_ { kLeft, kCenter, kRight };
enum class DevButtonStyle_ { kNormal, kDark };
static auto DevButtonStyleFromStr_(const char* strval) {
if (!strcmp(strval, "dark")) {
return DevButtonStyle_::kDark;
}
assert(!strcmp(strval, "normal"));
return DevButtonStyle_::kNormal;
}
static auto DevConsoleHAttachFromStr_(const char* strval) {
if (!strcmp(strval, "left")) {
return DevConsoleHAnchor_::kLeft;
} else if (!strcmp(strval, "right")) {
return DevConsoleHAnchor_::kRight;
}
assert(!strcmp(strval, "center"));
return DevConsoleHAnchor_::kCenter;
}
static auto TextMeshHAlignFromStr_(const char* strval) {
if (!strcmp(strval, "left")) {
return TextMesh::HAlign::kLeft;
} else if (!strcmp(strval, "right")) {
return TextMesh::HAlign::kRight;
}
assert(!strcmp(strval, "center"));
return TextMesh::HAlign::kCenter;
}
static auto TextMeshVAlignFromStr_(const char* strval) {
if (!strcmp(strval, "top")) {
return TextMesh::VAlign::kTop;
} else if (!strcmp(strval, "bottom")) {
return TextMesh::VAlign::kBottom;
} else if (!strcmp(strval, "none")) {
return TextMesh::VAlign::kNone;
}
assert(!strcmp(strval, "center"));
return TextMesh::VAlign::kCenter;
}
static auto XOffs(DevConsoleHAnchor_ attach) -> float {
switch (attach) {
case DevConsoleHAnchor_::kLeft:
return 0.0f;
case DevConsoleHAnchor_::kRight:
return g_base->graphics->screen_virtual_width();
case DevConsoleHAnchor_::kCenter:
return g_base->graphics->screen_virtual_width() * 0.5f;
}
assert(false);
return 0.0f;
}
static auto IsValidHungryChar_(uint32_t this_char) -> bool {
// Include letters, numbers, and underscore.
return ((this_char >= 65 && this_char <= 90)
|| (this_char >= 97 && this_char <= 122)
|| (this_char >= 48 && this_char <= 57) || this_char == '_');
}
static void DrawRect(RenderPass* pass, Mesh* mesh, float bottom, float x,
float y, float width, float height,
const Vector3f& bgcolor) {
SimpleComponent c(pass);
c.SetTransparent(true);
c.SetColor(bgcolor.x, bgcolor.y, bgcolor.z, 1.0f);
c.SetTexture(g_base->assets->SysTexture(SysTextureID::kCircle));
// Draw mesh bg.
if (mesh) {
auto xf = c.ScopedTransform();
c.Translate(x, y + bottom, kDevConsoleZDepth);
c.DrawMesh(mesh);
}
}
static void DrawText(RenderPass* pass, TextGroup* tgrp, float tscale,
float bottom, float x, float y, const Vector3f& fgcolor) {
SimpleComponent c(pass);
c.SetTransparent(true);
// Draw text.
{
auto xf = c.ScopedTransform();
c.Translate(x, y + bottom, kDevConsoleZDepth);
c.Scale(tscale, tscale, 1.0f);
int elem_count = tgrp->GetElementCount();
c.SetColor(fgcolor.x, fgcolor.y, fgcolor.z, 1.0f);
c.SetFlatness(1.0f);
for (int e = 0; e < elem_count; e++) {
c.SetTexture(tgrp->GetElementTexture(e));
c.DrawMesh(tgrp->GetElementMesh(e));
}
}
}
/// Anyone iterating through or mucking with the UI lists should hold one
/// of these while doing so; they simply keep us informed if we're editing
/// UI stuff where we shouldn't be.
class DevConsole::ScopedUILock_ {
public:
explicit ScopedUILock_(DevConsole* dev_console) : dev_console_{dev_console} {
assert(g_base->InLogicThread());
dev_console_->ui_lock_count_++;
}
~ScopedUILock_() {
assert(g_base->InLogicThread());
dev_console_->ui_lock_count_--;
assert(dev_console_->ui_lock_count_ >= 0);
}
private:
DevConsole* dev_console_;
};
/// Super-simple widget type for populating dev-console
/// (we don't want to depend on any of our full UI feature-sets).
class DevConsole::Widget_ {
public:
virtual ~Widget_() = default;
virtual auto HandleMouseDown(float mx, float my) -> bool { return false; }
virtual void HandleMouseUp(float mx, float my) {}
virtual void Draw(RenderPass* pass, float bottom) = 0;
};
class DevConsole::Text_ : public DevConsole::Widget_ {
public:
DevConsoleHAnchor_ h_attach;
TextMesh::HAlign h_align;
TextMesh::VAlign v_align;
float x;
float y;
float scale;
TextGroup text_group;
DevButtonStyle_ style;
Text_(const std::string& text, float x, float y, DevConsoleHAnchor_ h_attach,
TextMesh::HAlign h_align, TextMesh::VAlign v_align, float scale)
: h_attach{h_attach},
h_align(h_align),
v_align(v_align),
x{x},
y{y},
scale{scale} {
text_group.SetText(text, h_align, v_align);
}
void Draw(RenderPass* pass, float bottom) override {
auto fgcolor = Vector3f{0.8f, 0.7f, 0.8f};
DrawText(pass, &text_group, scale, bottom, x + XOffs(h_attach), y, fgcolor);
}
};
class DevConsole::Button_ : public DevConsole::Widget_ {
public:
DevConsoleHAnchor_ attach;
float x;
float y;
float width;
float height;
bool pressed{};
Object::Ref<Runnable> call;
NinePatchMesh mesh;
TextGroup text_group;
float text_scale;
DevButtonStyle_ style;
template <typename F>
Button_(const std::string& label, float text_scale, DevConsoleHAnchor_ attach,
float x, float y, float width, float height, float corner_radius,
DevButtonStyle_ style, const F& lambda)
: attach{attach},
x{x},
y{y},
width{width},
height{height},
call{NewLambdaRunnable(lambda)},
text_scale{text_scale},
style{style},
mesh(0.0f, 0.0f, 0.0f, width, height,
NinePatchMesh::BorderForRadius(corner_radius, width, height),
NinePatchMesh::BorderForRadius(corner_radius, height, width),
NinePatchMesh::BorderForRadius(corner_radius, width, height),
NinePatchMesh::BorderForRadius(corner_radius, height, width)) {
text_group.SetText(label, TextMesh::HAlign::kCenter,
TextMesh::VAlign::kCenter);
}
auto InUs(float mx, float my) -> bool {
mx -= XOffs(attach);
return (mx >= x && mx <= (x + width) && my >= y && my <= (y + height));
}
auto HandleMouseDown(float mx, float my) -> bool override {
if (InUs(mx, my)) {
pressed = true;
return true;
}
return false;
}
void HandleMouseUp(float mx, float my) override {
if (pressed) {
pressed = false;
if (InUs(mx, my)) {
if (call.Exists()) {
call.Get()->Run();
}
}
}
}
void Draw(RenderPass* pass, float bottom) override {
Vector3f fgcolor;
Vector3f bgcolor;
switch (style) {
case DevButtonStyle_::kDark:
fgcolor =
pressed ? Vector3f{0.0f, 0.0f, 0.0f} : Vector3f{0.8f, 0.7f, 0.8f};
bgcolor =
pressed ? Vector3f{0.6f, 0.5f, 0.6f} : Vector3f{0.16, 0.07f, 0.18f};
break;
default:
assert(style == DevButtonStyle_::kNormal);
fgcolor =
pressed ? Vector3f{0.0f, 0.0f, 0.0f} : Vector3f{0.8f, 0.7f, 0.8f};
bgcolor =
pressed ? Vector3f{0.8f, 0.7f, 0.8f} : Vector3f{0.25, 0.2f, 0.3f};
}
DrawRect(pass, &mesh, bottom, x + XOffs(attach), y, width, height, bgcolor);
DrawText(pass, &text_group, text_scale, bottom,
x + XOffs(attach) + width * 0.5f, y + height * 0.5f, fgcolor);
}
};
class DevConsole::ToggleButton_ : public DevConsole::Widget_ {
public:
DevConsoleHAnchor_ attach;
float x;
float y;
float width;
float height;
bool pressed{};
bool on{};
Object::Ref<Runnable> on_call;
Object::Ref<Runnable> off_call;
NinePatchMesh mesh;
TextGroup text_group;
float text_scale;
template <typename F, typename G>
ToggleButton_(const std::string& label, float text_scale,
DevConsoleHAnchor_ attach, float x, float y, float width,
float height, float corner_radius, const F& on_call,
const G& off_call)
: attach{attach},
x{x},
y{y},
width{width},
height{height},
on_call{NewLambdaRunnable(on_call)},
off_call{NewLambdaRunnable(off_call)},
text_scale{text_scale},
mesh(0.0f, 0.0f, 0.0f, width, height,
NinePatchMesh::BorderForRadius(corner_radius, width, height),
NinePatchMesh::BorderForRadius(corner_radius, height, width),
NinePatchMesh::BorderForRadius(corner_radius, width, height),
NinePatchMesh::BorderForRadius(corner_radius, height, width)) {
text_group.SetText(label, TextMesh::HAlign::kCenter,
TextMesh::VAlign::kCenter);
}
auto InUs(float mx, float my) -> bool {
mx -= XOffs(attach);
return (mx >= x && mx <= (x + width) && my >= y && my <= (y + height));
}
auto HandleMouseDown(float mx, float my) -> bool override {
if (InUs(mx, my)) {
pressed = true;
return true;
}
return false;
}
void HandleMouseUp(float mx, float my) override {
if (pressed) {
pressed = false;
if (InUs(mx, my)) {
on = !on;
auto&& call = on ? on_call : off_call;
if (call.Exists()) {
call.Get()->Run();
}
}
}
}
void Draw(RenderPass* pass, float bottom) override {
DrawRect(pass, &mesh, bottom, x + XOffs(attach), y, width, height,
pressed ? Vector3f{0.5f, 0.2f, 1.0f}
: on ? Vector3f{0.5f, 0.4f, 0.6f}
: Vector3f{0.25, 0.2f, 0.3f});
DrawText(pass, &text_group, text_scale, bottom,
x + XOffs(attach) + width * 0.5f, y + height * 0.5f,
pressed ? Vector3f{1.0f, 1.0f, 1.0f}
: on ? Vector3f{1.0f, 1.0f, 1.0f}
: Vector3f{0.8f, 0.7f, 0.8f});
}
};
class DevConsole::TabButton_ : public DevConsole::Widget_ {
public:
DevConsoleHAnchor_ attach;
float x;
float y;
float width;
float height;
bool pressed{};
bool selected{};
Object::Ref<Runnable> call;
TextGroup text_group;
NinePatchMesh mesh;
float text_scale;
template <typename F>
TabButton_(const std::string& label, bool selected, float text_scale,
DevConsoleHAnchor_ attach, float x, float y, float width,
float height, const F& call)
: attach{attach},
x{x},
y{y},
selected{selected},
width{width},
height{height},
call{NewLambdaRunnable(call)},
text_scale{text_scale},
mesh(0.0f, 0.0f, 0.0f, width, height,
NinePatchMesh::BorderForRadius(kDevConsoleTabButtonCornerRadius,
width, height),
NinePatchMesh::BorderForRadius(kDevConsoleTabButtonCornerRadius,
height, width),
NinePatchMesh::BorderForRadius(kDevConsoleTabButtonCornerRadius,
width, height),
0.0f) {
text_group.SetText(label, TextMesh::HAlign::kCenter,
TextMesh::VAlign::kCenter);
}
auto InUs(float mx, float my) -> bool {
mx -= XOffs(attach);
return (mx >= x && mx <= (x + width) && my >= y && my <= (y + height));
}
auto HandleMouseDown(float mx, float my) -> bool override {
if (InUs(mx, my) && !selected) {
pressed = true;
return true;
}
return false;
}
void HandleMouseUp(float mx, float my) override {
if (pressed) {
pressed = false;
if (InUs(mx, my)) {
// Technically this callback should cause us to be recreated in a
// selected state, but that happens in a deferred call, so go ahead
// and set ourself as selected already so we don't flash as
// unselected for a frame before the deferred call runs.
selected = true;
if (call.Exists()) {
call.Get()->Run();
}
}
}
}
void Draw(RenderPass* pass, float bottom) override {
DrawRect(pass, &mesh, bottom, x + XOffs(attach), y, width, height,
pressed ? Vector3f{0.4f, 0.2f, 0.8f}
: selected ? Vector3f{0.4f, 0.3f, 0.4f}
: Vector3f{0.25, 0.2f, 0.3f});
DrawText(pass, &text_group, text_scale, bottom,
x + XOffs(attach) + width * 0.5f, y + height * 0.5f,
pressed ? Vector3f{1.0f, 1.0f, 1.0f}
: selected ? Vector3f{1.0f, 1.0f, 1.0f}
: Vector3f{0.6f, 0.5f, 0.6f});
}
};
class DevConsole::OutputLine_ {
public:
OutputLine_(std::string s_in, double c)
: creation_time(c), s(std::move(s_in)) {}
double creation_time;
std::string s;
auto GetText() -> TextGroup& {
if (!s_mesh_.Exists()) {
s_mesh_ = Object::New<TextGroup>();
s_mesh_->SetText(s);
}
return *s_mesh_;
}
private:
Object::Ref<TextGroup> s_mesh_;
};
DevConsole::DevConsole() {
assert(g_base->InLogicThread());
std::string title = std::string("BallisticaKit ") + kEngineVersion + " ("
+ std::to_string(kEngineBuildNumber) + ")";
if (g_buildconfig.debug_build()) {
title += " (debug)";
}
if (g_buildconfig.test_build()) {
title += " (test)";
}
title_text_group_.SetText(title);
built_text_group_.SetText("Built: " __DATE__ " " __TIME__);
prompt_text_group_.SetText(">");
}
void DevConsole::RefreshTabButtons_() {
// IMPORTANT: This code should always be run in its own top level call and
// never directly from user code. Otherwise we can wind up mucking with
// the UI list as we're iterating through it.
assert(!ui_lock_count_);
// Ask the Python layer for the latest set of tabs.
tabs_ = g_base->python->objs()
.Get(BasePython::ObjID::kGetDevConsoleTabNamesCall)
.Call()
.ValueAsStringSequence();
// If we have tabs and none of them are selected, select the first.
if (!tabs_.empty()) {
bool found{};
for (auto&& tab : tabs_) {
if (active_tab_ == tab) {
found = true;
break;
}
}
if (!found) {
active_tab_ = tabs_.front();
}
}
// Now rebuild our buttons for them.
tab_buttons_.clear();
float bs = BaseScale();
float bwidth = 90.0f * bs;
float bheight = 26.0f * bs;
float bscale = 0.6f * bs;
float total_width = tabs_.size() * bwidth;
float x = total_width * -0.5f;
for (auto&& tab : tabs_) {
tab_buttons_.emplace_back(std::make_unique<TabButton_>(
tab, active_tab_ == tab, bscale, DevConsoleHAnchor_::kCenter, x,
-bheight, bwidth, bheight, [this, tab] {
active_tab_ = tab;
// Can't muck with UI from code called while iterating through UI.
// So defer it.
g_base->logic->event_loop()->PushCall([this] {
RefreshTabButtons_();
RefreshTabContents_();
});
}));
x += bwidth;
}
}
void DevConsole::RefreshTabContents_() {
BA_PRECONDITION(g_base->InLogicThread());
// IMPORTANT: This code should always be run in its own top level call and
// never directly from user code. Otherwise we can wind up mucking with
// the UI list as we're iterating through it.
assert(!ui_lock_count_);
// Consider any refresh requests fulfilled. Subsequent refresh-requests
// will generate a new refresh at this point.
refresh_pending_ = false;
// Clear to an empty slate.
widgets_.clear();
python_terminal_visible_ = false;
// Now ask the Python layer to fill this tab in.
PythonRef args(Py_BuildValue("(s)", active_tab_.c_str()), PythonRef::kSteal);
g_base->python->objs()
.Get(BasePython::ObjID::kAppDevConsoleDoRefreshTabCall)
.Call(args);
}
void DevConsole::AddText(const char* text, float x, float y,
const char* h_anchor_str, const char* h_align_str,
const char* v_align_str, float scale) {
auto h_anchor = DevConsoleHAttachFromStr_(h_anchor_str);
auto h_align = TextMeshHAlignFromStr_(h_align_str);
auto v_align = TextMeshVAlignFromStr_(v_align_str);
widgets_.emplace_back(
std::make_unique<Text_>(text, x, y, h_anchor, h_align, v_align, scale));
}
void DevConsole::AddButton(const char* label, float x, float y, float width,
float height, PyObject* call,
const char* h_anchor_str, float label_scale,
float corner_radius, const char* style_str) {
assert(g_base->InLogicThread());
auto style = DevButtonStyleFromStr_(style_str);
auto h_anchor = DevConsoleHAttachFromStr_(h_anchor_str);
widgets_.emplace_back(std::make_unique<Button_>(
label, label_scale, h_anchor, x, y, width, height, corner_radius, style,
[this, callref = PythonRef::Acquired(call)] {
if (callref.Get() != Py_None) {
callref.Call();
}
}));
}
void DevConsole::AddPythonTerminal() {
float bs = BaseScale();
widgets_.emplace_back(std::make_unique<Button_>(
"Exec", 0.5f * bs, DevConsoleHAnchor_::kRight, -33.0f * bs, 15.95f * bs,
32.0f * bs, 13.0f * bs, 2.0 * bs, DevButtonStyle_::kNormal,
[this] { Exec(); }));
python_terminal_visible_ = true;
}
void DevConsole::RequestRefresh() {
assert(g_base->InLogicThread());
// Schedule a refresh. If one is already scheduled but hasn't run, do
// nothing.
if (refresh_pending_) {
return;
}
refresh_pending_ = true;
g_base->logic->event_loop()->PushCall([this] { RefreshTabContents_(); });
}
auto DevConsole::HandleMouseDown(int button, float x, float y) -> bool {
assert(g_base->InLogicThread());
if (state_ == State_::kInactive) {
return false;
}
float bottom{Bottom_()};
// Pass to any buttons (in bottom-local space).
if (button == 1) {
// Make sure we don't muck with our UI while we're in here.
auto lock = ScopedUILock_(this);
for (auto&& button : tab_buttons_) {
if (button->HandleMouseDown(x, y - bottom)) {
return true;
}
}
for (auto&& button : widgets_) {
if (button->HandleMouseDown(x, y - bottom)) {
return true;
}
}
}
if (y < bottom) {
return false;
}
if (button == 1 && python_terminal_visible_) {
python_terminal_pressed_ = true;
}
return true;
}
auto DevConsole::Width() -> float {
return g_base->graphics->screen_virtual_width();
}
auto DevConsole::Height() -> float {
return g_base->graphics->screen_virtual_height() - Bottom_();
}
void DevConsole::HandleMouseUp(int button, float x, float y) {
assert(g_base->InLogicThread());
float bottom{Bottom_()};
if (button == 1) {
// Make sure we don't muck with our UI while we're in here.
auto lock = ScopedUILock_(this);
for (auto&& button : tab_buttons_) {
button->HandleMouseUp(x, y - bottom);
}
for (auto&& button : widgets_) {
button->HandleMouseUp(x, y - bottom);
}
}
if (button == 1 && python_terminal_pressed_) {
python_terminal_pressed_ = false;
if (y > bottom) {
// If we're not getting fed keyboard events and have a string editor
// available, invoke it.
if (!g_base->ui->UIHasDirectKeyboardInput()
&& g_base->platform->HaveStringEditor()) {
InvokeStringEditor_();
}
}
}
}
void DevConsole::InvokeStringEditor_() {
// If there's already a valid edit-adapter attached to us, do nothing.
if (string_edit_adapter_.Exists()
&& !g_base->python->CanPyStringEditAdapterBeReplaced(
string_edit_adapter_.Get())) {
return;
}
// Create a Python StringEditAdapter for this widget, passing ourself as
// the sole arg.
auto result = g_base->python->objs()
.Get(BasePython::ObjID::kDevConsoleStringEditAdapterClass)
.Call();
if (!result.Exists()) {
Log(LogLevel::kError, "Error invoking string edit dialog.");
return;
}
// If this new one is already marked replacable, it means it wasn't able
// to register as the active one, so we can ignore it.
if (g_base->python->CanPyStringEditAdapterBeReplaced(result.Get())) {
return;
}
// Ok looks like we're good; store the adapter as our active one.
string_edit_adapter_ = result;
g_base->platform->InvokeStringEditor(string_edit_adapter_.Get());
}
void DevConsole::set_input_string(const std::string& val) {
assert(g_base->InLogicThread());
input_string_ = val;
input_text_dirty_ = true;
// Move carat to end.
carat_char_ =
static_cast<int>(Utils::UnicodeFromUTF8(input_string_, "fj43t").size());
assert(CaratCharValid_());
carat_dirty_ = true;
}
void DevConsole::InputAdapterFinish() {
assert(g_base->InLogicThread());
string_edit_adapter_.Release();
}
auto DevConsole::HandleKeyPress(const SDL_Keysym* keysym) -> bool {
assert(g_base->InLogicThread());
// Any presses or releases cancels repeat actions.
key_repeater_.Clear();
if (state_ == State_::kInactive) {
return false;
}
// Stuff we always look for.
switch (keysym->sym) {
case SDLK_ESCAPE:
Dismiss();
return true;
default:
break;
}
// Stuff we look for only when direct keyboard input is enabled and our
// Python terminal is up.
if (python_terminal_visible_ && g_base->ui->UIHasDirectKeyboardInput()) {
bool do_carat_right{};
bool do_hungry_carat_right{};
bool do_carat_left{};
bool do_hungry_carat_left{};
bool do_history_up{};
bool do_history_down{};
bool do_backspace{};
bool do_forward_delete{};
bool do_hungry_backspace{};
bool do_hungry_forward_delete{};
bool do_move_to_end{};
bool do_move_to_beginning{};
bool do_kill_line{};
switch (keysym->sym) {
case SDLK_BACKSPACE: {
if (keysym->mod & KMOD_ALT) {
do_hungry_backspace = true;
} else {
do_backspace = true;
}
break;
}
case SDLK_DELETE: {
if (keysym->mod & KMOD_ALT) {
do_hungry_forward_delete = true;
} else {
do_forward_delete = true;
}
break;
}
case SDLK_HOME:
do_move_to_beginning = true;
break;
case SDLK_END:
do_move_to_end = true;
break;
case SDLK_UP:
do_history_up = true;
break;
case SDLK_DOWN:
do_history_down = true;
break;
case SDLK_RIGHT:
if (keysym->mod & KMOD_ALT) {
do_hungry_carat_right = true;
} else {
do_carat_right = true;
}
break;
case SDLK_LEFT:
if (keysym->mod & KMOD_ALT) {
do_hungry_carat_left = true;
} else {
do_carat_left = true;
}
break;
case SDLK_KP_ENTER:
case SDLK_RETURN: {
Exec();
break;
}
// Wheeee emacs key shortcuts!!
case SDLK_n:
if (keysym->mod & KMOD_CTRL) {
do_history_down = true;
}
break;
case SDLK_f:
if (keysym->mod & KMOD_CTRL) {
do_carat_right = true;
} else if (keysym->mod & KMOD_ALT) {
do_hungry_carat_right = true;
}
break;
case SDLK_b:
if (keysym->mod & KMOD_CTRL) {
do_carat_left = true;
} else if (keysym->mod & KMOD_ALT) {
do_hungry_carat_left = true;
}
break;
case SDLK_p:
if (keysym->mod & KMOD_CTRL) {
do_history_up = true;
}
break;
case SDLK_a:
if (keysym->mod & KMOD_CTRL) {
do_move_to_beginning = true;
}
break;
case SDLK_d:
if (keysym->mod & KMOD_CTRL) {
do_forward_delete = true;
} else if (keysym->mod & KMOD_ALT) {
do_hungry_forward_delete = true;
}
break;
case SDLK_e:
if (keysym->mod & KMOD_CTRL) {
do_move_to_end = true;
}
break;
case SDLK_k:
if (keysym->mod & KMOD_CTRL) {
do_kill_line = true;
}
default: {
break;
}
}
if (do_kill_line) {
auto unichars = Utils::UnicodeFromUTF8(input_string_, "fjco38");
assert(CaratCharValid_());
unichars.resize(carat_char_);
assert(CaratCharValid_());
input_string_ = Utils::UTF8FromUnicode(unichars);
input_text_dirty_ = true;
carat_dirty_ = true;
}
if (do_move_to_beginning) {
carat_char_ = 0;
assert(CaratCharValid_());
carat_dirty_ = true;
}
if (do_move_to_end) {
// Move carat to end.
carat_char_ = static_cast<int>(
Utils::UnicodeFromUTF8(input_string_, "fj43t").size());
assert(CaratCharValid_());
carat_dirty_ = true;
}
if (do_hungry_backspace || do_hungry_carat_left) {
auto do_delete = do_hungry_backspace;
key_repeater_ = Repeater::New(
g_base->app_adapter->GetKeyRepeatDelay(),
g_base->app_adapter->GetKeyRepeatInterval(), [this, do_delete] {
auto unichars = Utils::UnicodeFromUTF8(input_string_, "fjco38");
bool found_valid{};
// Delete/move until we've found at least one valid char and the
// stop at the first invalid one.
while (carat_char_ > 0) {
assert(CaratCharValid_());
auto this_char = unichars[carat_char_ - 1];
auto is_valid = IsValidHungryChar_(this_char);
if (found_valid && !is_valid) {
break;
}
if (is_valid) {
found_valid = true;
}
if (do_delete) {
unichars.erase(unichars.begin() + carat_char_ - 1);
}
carat_char_ -= 1;
assert(CaratCharValid_());
}
if (do_delete) {
input_string_ = Utils::UTF8FromUnicode(unichars);
input_text_dirty_ = true;
}
carat_dirty_ = true;
});
}
if (do_hungry_forward_delete || do_hungry_carat_right) {
auto do_delete = do_hungry_forward_delete;
key_repeater_ = Repeater::New(
g_base->app_adapter->GetKeyRepeatDelay(),
g_base->app_adapter->GetKeyRepeatInterval(), [this, do_delete] {
auto unichars = Utils::UnicodeFromUTF8(input_string_, "fjco38");
bool found_valid{};
// Move until we've found at least one valid char and the
// stop at the first invalid one.
while (carat_char_ < static_cast<int>(unichars.size())) {
assert(CaratCharValid_());
auto this_char = unichars[carat_char_];
auto is_valid = IsValidHungryChar_(this_char);
if (found_valid && !is_valid) {
break;
}
if (is_valid) {
found_valid = true;
}
if (do_delete) {
unichars.erase(unichars.begin() + carat_char_);
} else {
carat_char_ += 1;
}
assert(CaratCharValid_());
}
if (do_delete) {
input_string_ = Utils::UTF8FromUnicode(unichars);
input_text_dirty_ = true;
}
carat_dirty_ = true;
});
}
if (do_backspace) {
key_repeater_ = Repeater::New(
g_base->app_adapter->GetKeyRepeatDelay(),
g_base->app_adapter->GetKeyRepeatInterval(), [this] {
auto unichars = Utils::UnicodeFromUTF8(input_string_, "fjco38");
if (!unichars.empty() && carat_char_ > 0) {
assert(CaratCharValid_());
unichars.erase(unichars.begin() + carat_char_ - 1);
input_string_ = Utils::UTF8FromUnicode(unichars);
input_text_dirty_ = true;
carat_char_ -= 1;
assert(CaratCharValid_());
carat_dirty_ = true;
}
});
}
if (do_forward_delete) {
key_repeater_ = Repeater::New(
g_base->app_adapter->GetKeyRepeatDelay(),
g_base->app_adapter->GetKeyRepeatInterval(), [this] {
auto unichars = Utils::UnicodeFromUTF8(input_string_, "fjco33");
if (!unichars.empty()
&& carat_char_ < static_cast<int>(unichars.size())) {
assert(CaratCharValid_());
unichars.erase(unichars.begin() + carat_char_);
input_string_ = Utils::UTF8FromUnicode(unichars);
input_text_dirty_ = true;
carat_dirty_ = true; // Didn't move but might change size.
assert(CaratCharValid_());
}
});
}
if (do_carat_left || do_carat_right) {
key_repeater_ = Repeater::New(
g_base->app_adapter->GetKeyRepeatDelay(),
g_base->app_adapter->GetKeyRepeatInterval(),
[do_carat_left, do_carat_right, this] {
int offset = do_carat_right ? 1 : -1;
carat_char_ = std::clamp(
carat_char_ + offset, 0,
static_cast<int>(
Utils::UnicodeFromUTF8(input_string_, "fffwe").size()));
assert(CaratCharValid_());
carat_dirty_ = true;
});
}
if ((do_history_up || do_history_down) && !input_history_.empty()) {
if (do_history_up) {
input_history_position_++;
} else {
input_history_position_--;
}
int input_history_position_used =
(input_history_position_ - 1)
% static_cast<int>(input_history_.size());
int j = 0;
for (auto& i : input_history_) {
if (j == input_history_position_used) {
input_string_ = i;
carat_char_ = static_cast<int>(
Utils::UnicodeFromUTF8(input_string_, "fffwe").size());
assert(CaratCharValid_());
input_text_dirty_ = true;
carat_dirty_ = true;
break;
}
j++;
}
}
return true;
}
// By default don't claim key events; we want to be able to show the
// console while still playing/navigating normally.
return false;
}
auto DevConsole::HandleTextEditing(const std::string& text) -> bool {
assert(g_base->InLogicThread());
if (state_ == State_::kInactive) {
return false;
}
assert(CaratCharValid_());
auto unichars = Utils::UnicodeFromUTF8(input_string_, "jfof8");
auto addunichars = Utils::UnicodeFromUTF8(text, "jfoef8");
unichars.insert(unichars.begin() + carat_char_, addunichars.begin(),
addunichars.end());
input_string_ = Utils::UTF8FromUnicode(unichars);
input_text_dirty_ = true;
carat_char_ += addunichars.size();
assert(CaratCharValid_());
carat_dirty_ = true;
return true;
}
auto DevConsole::HandleKeyRelease(const SDL_Keysym* keysym) -> bool {
// Any presses or releases cancels repeat actions.
key_repeater_.Clear();
// Otherwise absorb *all* key-ups when we're active.
return state_ != State_::kInactive;
}
void DevConsole::Exec() {
BA_PRECONDITION(g_base->InLogicThread());
if (!input_enabled_) {
Log(LogLevel::kWarning, "Console input is not allowed yet.");
return;
}
input_history_position_ = 0;
if (input_string_ == "clear") {
last_line_.clear();
output_lines_.clear();
} else {
SubmitPythonCommand_(input_string_);
}
input_history_.push_front(input_string_);
if (input_history_.size() > 100) {
input_history_.pop_back();
}
input_string_.resize(0);
carat_char_ = 0;
assert(CaratCharValid_());
input_text_dirty_ = true;
carat_dirty_ = true;
}
// Just for sanity testing.
auto DevConsole::CaratCharValid_() -> bool {
return carat_char_ >= 0
&& carat_char_ <= static_cast<int>(
Utils::UnicodeFromUTF8(input_string_, "fwewffe").size());
}
void DevConsole::SubmitPythonCommand_(const std::string& command) {
assert(g_base);
g_base->logic->event_loop()->PushCall([command, this] {
// These are always run in whichever context is 'visible'.
ScopedSetContext ssc(g_base->app_mode()->GetForegroundContext());
PythonCommand cmd(command, "<console>");
if (!g_core->user_ran_commands) {
g_core->user_ran_commands = true;
}
if (cmd.CanEval()) {
auto obj = cmd.Eval(true, nullptr, nullptr);
if (obj.Exists() && obj.Get() != Py_None) {
Print(obj.Repr() + "\n");
}
} else {
// Not eval-able; just exec it.
cmd.Exec(true, nullptr, nullptr);
}
});
}
void DevConsole::EnableInput() {
assert(g_base->InLogicThread());
input_enabled_ = true;
}
void DevConsole::Dismiss() {
assert(g_base->InLogicThread());
if (state_ == State_::kInactive) {
return;
}
state_prev_ = state_;
state_ = State_::kInactive;
transition_start_ = g_base->logic->display_time();
}
void DevConsole::ToggleState() {
assert(g_base->InLogicThread());
state_prev_ = state_;
switch (state_) {
case State_::kInactive:
state_ = State_::kMini;
// Can't muck with UI from code (potentially) called while iterating
// through UI. So defer it.
g_base->logic->event_loop()->PushCall([this] {
RefreshTabButtons_();
RefreshTabContents_();
});
break;
case State_::kMini:
state_ = State_::kFull;
// Can't muck with UI from code (potentially) called while iterating
// through UI. So defer it.
g_base->logic->event_loop()->PushCall([this] { RefreshTabContents_(); });
break;
case State_::kFull:
state_ = State_::kInactive;
break;
}
g_base->audio->PlaySound(g_base->assets->SysSound(SysSoundID::kBlip));
transition_start_ = g_base->logic->display_time();
}
void DevConsole::Print(const std::string& s_in) {
assert(g_base->InLogicThread());
std::string s = Utils::GetValidUTF8(s_in.c_str(), "cspr");
last_line_ += s;
std::vector<std::string> broken_up;
g_base->text_graphics->BreakUpString(
last_line_.c_str(), kDevConsoleStringBreakUpSize, &broken_up);
// Spit out all completed lines and keep the last one as lastline.
for (size_t i = 0; i < broken_up.size() - 1; i++) {
output_lines_.emplace_back(broken_up[i], g_base->logic->display_time());
if (output_lines_.size() > kDevConsoleLineLimit) {
output_lines_.pop_front();
}
}
last_line_ = broken_up[broken_up.size() - 1];
last_line_mesh_dirty_ = true;
}
auto DevConsole::Bottom_() const -> float {
float vh = g_base->graphics->screen_virtual_height();
float ratio =
(g_base->logic->display_time() - transition_start_) / kTransitionSeconds;
float bottom;
// NOTE: Originally I was tweaking this based on UI scale, but I decided
// that it would be a better idea to have a constant value everywhere.
// dev-consoles are not meant to be especially pretty and I think it is
// more important for them to be able to be written to a known hard-coded
// mini-size.
float mini_size = 100.0f;
// Now that we have tabs and drop-shadows hanging down, we have to
// overshoot the top of the screen when transitioning out.
float top_buffer = 100.0f;
if (state_ == State_::kMini) {
bottom = vh - mini_size;
} else {
bottom = vh - vh * kDevConsoleSize;
}
if (g_base->logic->display_time() - transition_start_ < kTransitionSeconds) {
float from_height;
if (state_prev_ == State_::kMini) {
from_height = vh - mini_size;
} else if (state_prev_ == State_::kFull) {
from_height = vh - vh * kDevConsoleSize;
} else {
from_height = vh + top_buffer;
}
float to_height;
if (state_ == State_::kMini) {
to_height = vh - mini_size;
} else if (state_ == State_::kFull) {
to_height = vh - vh * kDevConsoleSize;
} else {
to_height = vh + top_buffer;
}
bottom = to_height * ratio + from_height * (1.0 - ratio);
}
return bottom;
}
void DevConsole::Draw(FrameDef* frame_def) {
float bs = BaseScale();
RenderPass* pass = frame_def->overlay_front_pass();
// If we're not yet transitioning in for the first time OR have completed
// transitioning out, do nothing.
if (transition_start_ <= 0.0
|| (state_ == State_::kInactive
&& ((g_base->logic->display_time() - transition_start_)
>= kTransitionSeconds))) {
return;
}
float bottom = Bottom_();
float border_height{3.0f};
{
bg_mesh_.SetPositionAndSize(0, bottom, kDevConsoleZDepth,
pass->virtual_width(),
(pass->virtual_height() - bottom));
stripe_mesh_.SetPositionAndSize(0, bottom + 15.0f * bs, kDevConsoleZDepth,
pass->virtual_width(), 15.0f * bs);
border_mesh_.SetPositionAndSize(0, bottom - border_height * bs,
kDevConsoleZDepth, pass->virtual_width(),
border_height * bs);
{
SimpleComponent c(pass);
c.SetTransparent(true);
c.SetColor(0, 0, 0.1f, 0.9f);
c.DrawMesh(&bg_mesh_);
c.Submit();
if (python_terminal_visible_) {
c.SetColor(1.0f, 1.0f, 1.0f, 0.1f);
c.DrawMesh(&stripe_mesh_);
c.Submit();
}
c.SetColor(0.25f, 0.2f, 0.3f, 1.0f);
c.DrawMesh(&border_mesh_);
}
}
// Drop shadow.
{
SimpleComponent c(pass);
c.SetTransparent(true);
c.SetColor(0.03, 0, 0.09, 0.9f);
c.SetTexture(g_base->assets->SysTexture(SysTextureID::kSoftRectVertical));
{
auto scissor = c.ScopedScissor({0.0f, 0.0f, pass->virtual_width(),
bottom - (border_height * 0.75f) * bs});
auto xf = c.ScopedTransform();
c.Translate(pass->virtual_width() * 0.5f, bottom + 160.0f);
c.Scale(pass->virtual_width() * 1.2f, 600.0f);
c.DrawMeshAsset(g_base->assets->SysMesh(SysMeshID::kImage1x1));
}
}
if (python_terminal_visible_) {
if (input_text_dirty_) {
input_text_group_.SetText(input_string_);
input_text_dirty_ = false;
}
{
SimpleComponent c(pass);
c.SetFlatness(1.0f);
c.SetTransparent(true);
c.SetColor(0.4f, 0.33f, 0.45f, 0.8f);
// Build.
int elem_count = built_text_group_.GetElementCount();
for (int e = 0; e < elem_count; e++) {
c.SetTexture(built_text_group_.GetElementTexture(e));
{
auto xf = c.ScopedTransform();
c.Translate(pass->virtual_width() - 115.0f * bs, bottom + 1.9f * bs,
kDevConsoleZDepth);
c.Scale(0.35f * bs, 0.35f * bs, 1.0f);
c.DrawMesh(built_text_group_.GetElementMesh(e));
}
}
// Title.
elem_count = title_text_group_.GetElementCount();
for (int e = 0; e < elem_count; e++) {
c.SetTexture(title_text_group_.GetElementTexture(e));
{
auto xf = c.ScopedTransform();
c.Translate(10.0f * bs, bottom + 1.9f * bs, kDevConsoleZDepth);
c.Scale(0.35f * bs, 0.35f * bs, 1.0f);
c.DrawMesh(title_text_group_.GetElementMesh(e));
}
}
// Prompt.
elem_count = prompt_text_group_.GetElementCount();
for (int e = 0; e < elem_count; e++) {
c.SetTexture(prompt_text_group_.GetElementTexture(e));
c.SetColor(1, 1, 1, 1);
{
auto xf = c.ScopedTransform();
c.Translate(5.0f * bs, bottom + 14.5f * bs, kDevConsoleZDepth);
c.Scale(0.5f * bs, 0.5f * bs, 1.0f);
c.DrawMesh(prompt_text_group_.GetElementMesh(e));
}
}
// Input line.
elem_count = input_text_group_.GetElementCount();
for (int e = 0; e < elem_count; e++) {
c.SetTexture(input_text_group_.GetElementTexture(e));
{
auto xf = c.ScopedTransform();
c.Translate(15.0f * bs, bottom + 14.5f * bs, kDevConsoleZDepth);
c.Scale(0.5f * bs, 0.5f * bs, 1.0f);
c.DrawMesh(input_text_group_.GetElementMesh(e));
}
}
}
// Carat.
if (!carat_mesh_.Exists() || carat_dirty_) {
// Note: we explicitly update here if carat is dirty because
// that updates last_carat_change_time_ which affects whether
// we draw or not. GetCaratX_() only updates it *if* we draw.
UpdateCarat_();
}
millisecs_t app_time = pass->frame_def()->app_time_millisecs();
millisecs_t since_change = app_time - last_carat_x_change_time_;
if (since_change < 300 || since_change % 1000 < 500) {
SimpleComponent c(pass);
c.SetTransparent(true);
c.SetTexture(g_base->assets->SysTexture(SysTextureID::kShadow));
c.SetColor(0.8, 0.0, 1.0, 0.3f);
{
auto xf = c.ScopedTransform();
auto carat_x = GetCaratX_();
c.Translate(15.0f * bs, bottom + 14.5f * bs, kDevConsoleZDepth);
c.Scale(0.5f * bs, 0.5f * bs, 1.0f);
c.Translate(carat_x, 0.0f, 0.0f);
c.DrawMesh(carat_glow_mesh_.Get());
}
c.SetTexture(g_base->assets->SysTexture(SysTextureID::kShadowSharp));
c.SetColor(1.0, 1.0, 1.0, 1.0f);
{
auto xf = c.ScopedTransform();
auto carat_x = GetCaratX_();
c.Translate(15.0f * bs, bottom + 14.5f * bs, kDevConsoleZDepth);
c.Scale(0.5f * bs, 0.5f * bs, 1.0f);
c.Translate(carat_x, 0.0f, 0.0f);
c.DrawMesh(carat_mesh_.Get());
}
}
// Output lines.
{
SimpleComponent c(pass);
c.SetTransparent(true);
c.SetColor(1, 1, 1, 1);
c.SetFlatness(1.0f);
float draw_scale = 0.6f;
float v_inc = 18.0f;
float h = 0.5f
* (g_base->graphics->screen_virtual_width()
- (kDevConsoleStringBreakUpSize * draw_scale));
float v = bottom + 32.0f * bs;
if (!last_line_.empty()) {
if (last_line_mesh_dirty_) {
if (!last_line_mesh_group_.Exists()) {
last_line_mesh_group_ = Object::New<TextGroup>();
}
last_line_mesh_group_->SetText(last_line_);
last_line_mesh_dirty_ = false;
}
int elem_count = last_line_mesh_group_->GetElementCount();
for (int e = 0; e < elem_count; e++) {
c.SetTexture(last_line_mesh_group_->GetElementTexture(e));
{
auto xf = c.ScopedTransform();
c.Translate(h, v + 2, kDevConsoleZDepth);
c.Scale(draw_scale, draw_scale);
c.DrawMesh(last_line_mesh_group_->GetElementMesh(e));
}
}
v += v_inc;
}
for (auto i = output_lines_.rbegin(); i != output_lines_.rend(); i++) {
int elem_count = i->GetText().GetElementCount();
for (int e = 0; e < elem_count; e++) {
c.SetTexture(i->GetText().GetElementTexture(e));
{
auto xf = c.ScopedTransform();
c.Translate(h, v + 2, kDevConsoleZDepth);
c.Scale(draw_scale, draw_scale);
c.DrawMesh(i->GetText().GetElementMesh(e));
}
}
v += v_inc;
if (v > pass->virtual_height() + v_inc) {
break;
}
}
}
}
// Tab Buttons.
{
// Make sure we don't muck with our UI while we're in here.
auto lock = ScopedUILock_(this);
for (auto&& button : tab_buttons_) {
button->Draw(pass, bottom);
}
}
// Buttons.
{
// Make sure we don't muck with our UI while we're in here.
auto lock = ScopedUILock_(this);
for (auto&& button : widgets_) {
button->Draw(pass, bottom);
}
}
}
auto DevConsole::BaseScale() const -> float {
switch (g_base->ui->scale()) {
case UIScale::kLarge:
return 1.5f;
case UIScale::kMedium:
return 1.75f;
case UIScale::kSmall:
case UIScale::kLast:
return 2.0f;
}
FatalError("Unhandled scale.");
return 1.0f;
}
void DevConsole::StepDisplayTime() {
assert(g_base->InLogicThread());
// IMPORTANT: We can muck with UI here so make sure noone is iterating
// through or editing it.
assert(!ui_lock_count_);
// If we're inactive, blow away all our stuff once we transition fully
// off screen. This will kill any Python stuff attached to our widgets
// so things can clean themselves up.
if (state_ == State_::kInactive && !tab_buttons_.empty()) {
if ((g_base->logic->display_time() - transition_start_)
>= kTransitionSeconds) {
// Reset to a blank slate but *don't refresh anything (that will
// happen once we get vis'ed again).
tab_buttons_.clear();
widgets_.clear();
python_terminal_visible_ = false;
}
}
}
auto DevConsole::PasteFromClipboard() -> bool {
if (state_ != State_::kInactive) {
if (python_terminal_visible_) {
if (g_base->app_adapter->ClipboardIsSupported()) {
if (g_base->app_adapter->ClipboardHasText()) {
auto text = g_base->app_adapter->ClipboardGetText();
if (strstr(text.c_str(), "\n") || strstr(text.c_str(), "\r")) {
g_base->audio->PlaySound(
g_base->assets->SysSound(SysSoundID::kErrorBeep));
ScreenMessage("Can only paste single lines of text.",
Vector3f(1.0f, 0.0f, 0.0f));
} else {
HandleTextEditing(text);
}
// Ok, we either pasted or complained, so consider it handled.
return true;
}
}
}
}
return false;
}
void DevConsole::UpdateCarat_() {
last_carat_x_change_time_ = g_core->GetAppTimeMillisecs();
auto unichars = Utils::UnicodeFromUTF8(input_string_, "fjfwef");
auto unichars_clamped = unichars;
unichars_clamped.resize(carat_char_);
auto clamped_str = Utils::UTF8FromUnicode(unichars_clamped);
carat_x_ = g_base->text_graphics->GetStringWidth(clamped_str);
// Use a base width if we're not covering a char, and use the char's width
// if we are.
float width = 14.0f;
if (carat_char_ < static_cast<int>(unichars.size())) {
std::vector<uint32_t> covered_char{unichars[carat_char_]};
auto covered_char_str = Utils::UTF8FromUnicode(covered_char);
width =
std::max(3.0f, g_base->text_graphics->GetStringWidth(covered_char_str));
}
float height = 32.0f;
float x_extend = 15.0f;
float y_extend = 20.0f;
float x_offset = 2.0f;
float y_offset = -0.0f;
float corner_radius = 20.0f;
float width_fin = width + x_extend * 2.0f;
float height_fin = height + y_extend * 2.0f;
float x_border =
NinePatchMesh::BorderForRadius(corner_radius, width_fin, height_fin);
float y_border =
NinePatchMesh::BorderForRadius(corner_radius, height_fin, width_fin);
carat_glow_mesh_ = Object::New<NinePatchMesh>(
-x_extend + x_offset, -y_extend + y_offset, 0.0f, width_fin, height_fin,
x_border, y_border, x_border, y_border);
corner_radius = 3.0f;
x_extend = 0.0f;
y_extend = -3.0f;
x_offset = 1.0f;
y_offset = 0.0f;
width_fin = width + x_extend * 2.0f;
height_fin = height + y_extend * 2.0f;
x_border =
NinePatchMesh::BorderForRadius(corner_radius, width_fin, height_fin);
y_border =
NinePatchMesh::BorderForRadius(corner_radius, height_fin, width_fin);
carat_mesh_ = Object::New<NinePatchMesh>(
-x_extend + x_offset, -y_extend + y_offset, 0.0f, width_fin, height_fin,
x_border, y_border, x_border, y_border);
}
auto DevConsole::GetCaratX_() -> float {
if (carat_dirty_) {
UpdateCarat_();
carat_dirty_ = false;
}
return carat_x_;
}
} // namespace ballistica::base