diff --git a/TODO.md b/TODO.md
index 0072dee..340e2c0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,25 +1,30 @@
# Specific projects
## Now:
+- outfitter
+ - Fix input (turn and multipliers)
+ - Indicator for "can't buy / sell"
+ - Buy multiple outfits
+ - Show money
+ - Show owned outfits
- init on resize
- hide ui element
- zoom limits
-- text scrolling
- scrollbox scroll limits
-- clean up content
- Clean up state api
- Clean up & document UI api
- Clean up scripting errors
- Mouse colliders
- Fade sprites and text in scrollbox
- Selection while flying
-- outfitter
- fps textbox positioning
- shield generation curve
- clippy & rules
- reject unknown toml
+- styled text & better content formatting
## Small jobs
+- Scroll direction config
- Better planet icons in radar
- Clean up sprite content (and content in general)
- Check game version in config
diff --git a/content/system.toml b/content/system.toml
index 9307fdf..bd9cb75 100644
--- a/content/system.toml
+++ b/content/system.toml
@@ -26,6 +26,16 @@ of the human species.
Earth is also the capital of the Republic. Representative government becomes complicated when
one planet has a greater population than a hundred planets elsewhere. As a result,
settlements of less than a million are grouped together into planetary districts that
+elect a single representative between them - a source of much frustration in the frontier worlds.
+ The ancestral home world of humanity, Earth has a population twice that of any other inhabited planet.
+Sprawling cities cover large portions of its surface, many of them overcrowded and dangerous.
+Some people work to scrape together enough money to leave, while at the same time others, born
+on distant worlds, make a pilgrimage of sorts to see this planet that once cradled the entirety
+of the human species.
+
+ Earth is also the capital of the Republic. Representative government becomes complicated when
+one planet has a greater population than a hundred planets elsewhere. As a result,
+settlements of less than a million are grouped together into planetary districts that
elect a single representative between them - a source of much frustration in the frontier worlds.
"""
object.earth.landable.image = "ui::landscape::test"
diff --git a/content/ui/landed.rhai b/content/ui/landed.rhai
index d491fd8..eebc866 100644
--- a/content/ui/landed.rhai
+++ b/content/ui/landed.rhai
@@ -69,6 +69,7 @@ fn init(state) {
)
);
textbox::font_sans("desc");
+ textbox::enable_scroll("desc", true);
if state.player_ship().is_landed() {
textbox::set_text("desc", state.player_ship().landed_on().desc());
}
diff --git a/content/ui/outfitter.rhai b/content/ui/outfitter.rhai
index cfabd09..3ae9926 100644
--- a/content/ui/outfitter.rhai
+++ b/content/ui/outfitter.rhai
@@ -99,6 +99,8 @@ fn init(state) {
)
);
textbox::font_mono("ship_stats");
+ textbox::enable_scroll("ship_stats", true);
+
sprite::add(
"outfit_bg",
@@ -146,6 +148,8 @@ fn init(state) {
);
textbox::font_serif("outfit_desc");
textbox::set_text("outfit_desc", "");
+ textbox::enable_scroll("outfit_desc", true);
+
textbox::add(
@@ -159,6 +163,7 @@ fn init(state) {
);
textbox::font_mono("outfit_stats");
textbox::set_text("outfit_stats", "");
+ textbox::enable_scroll("outfit_stats", true);
sprite::add(
@@ -348,7 +353,7 @@ fn init(state) {
fn event(state, event) {
// TODO: update on ship outfit change only
- update_ship_info(state);
+ //update_ship_info(state);
if type_of(event) == "MouseHoverEvent" {
let element = event.element();
@@ -572,6 +577,9 @@ fn update_outfit_info(selected_outfit) {
textbox::set_text("outfit_name", outfit.display_name());
textbox::set_text("outfit_desc", outfit.desc());
textbox::set_text("outfit_stats", stats);
+
+ textbox::reset_scroll("outfit_stats");
+ textbox::reset_scroll("outfit_name");
}
}
diff --git a/crates/render/src/ui/api/event.rs b/crates/render/src/ui/api/event.rs
index ff65abb..eb8549f 100644
--- a/crates/render/src/ui/api/event.rs
+++ b/crates/render/src/ui/api/event.rs
@@ -1,5 +1,18 @@
use rhai::{CustomType, ImmutableString, TypeBuilder};
+/// "Do-nothing" event, used to stop processing input without triggering user code.
+/// This allows us to scroll a textbox inside a scrollbox without moving the scrollbox,
+/// for example.
+#[derive(Debug, Clone)]
+pub struct NullEvent {}
+
+impl CustomType for NullEvent {
+ // We implement build, but NullEvents do NOT trigger the event callback.
+ fn build(mut builder: TypeBuilder) {
+ builder.with_name("NullEvent");
+ }
+}
+
#[derive(Debug, Clone)]
pub struct MouseClickEvent {
pub down: bool,
diff --git a/crates/render/src/ui/api/functions/textbox.rs b/crates/render/src/ui/api/functions/textbox.rs
index 59131e1..1b7f94a 100644
--- a/crates/render/src/ui/api/functions/textbox.rs
+++ b/crates/render/src/ui/api/functions/textbox.rs
@@ -374,5 +374,53 @@ pub fn build_textbox_module(
e.set_style(&mut font.borrow_mut(), Style::Italic);
});
+ let state = state_src.clone();
+ let _ = FuncRegistration::new("enable_scroll")
+ .with_namespace(FnNamespace::Internal)
+ .set_into_module(&mut module, move |name: ImmutableString, enable: bool| {
+ let mut ui_state = state.borrow_mut();
+ let e = match ui_state.get_mut_by_name(&name) {
+ Some(UiElement::SubElement { element, .. }) => match &mut **element {
+ UiElement::Text(x) => x,
+ _ => {
+ error!("called `textbox::enable_scroll` on an invalid name `{name}`");
+ return;
+ }
+ },
+
+ Some(UiElement::Text(x)) => x,
+ _ => {
+ error!("called `textbox::enable_scroll` on an invalid name `{name}`");
+ return;
+ }
+ };
+
+ e.set_scroll(enable);
+ });
+
+ let state = state_src.clone();
+ let _ = FuncRegistration::new("reset_scroll")
+ .with_namespace(FnNamespace::Internal)
+ .set_into_module(&mut module, move |name: ImmutableString| {
+ let mut ui_state = state.borrow_mut();
+ let e = match ui_state.get_mut_by_name(&name) {
+ Some(UiElement::SubElement { element, .. }) => match &mut **element {
+ UiElement::Text(x) => x,
+ _ => {
+ error!("called `textbox::reset_scroll` on an invalid name `{name}`");
+ return;
+ }
+ },
+
+ Some(UiElement::Text(x)) => x,
+ _ => {
+ error!("called `textbox::reset_scroll` on an invalid name `{name}`");
+ return;
+ }
+ };
+
+ e.reset_scroll();
+ });
+
return module;
}
diff --git a/crates/render/src/ui/elements/scrollbox.rs b/crates/render/src/ui/elements/scrollbox.rs
index f2f4a10..a4c39dd 100644
--- a/crates/render/src/ui/elements/scrollbox.rs
+++ b/crates/render/src/ui/elements/scrollbox.rs
@@ -2,7 +2,10 @@ use nalgebra::Vector2;
use rhai::{Dynamic, ImmutableString};
use super::super::api::Rect;
-use crate::{InputEvent, RenderInput, RenderState};
+use crate::{
+ ui::api::{MouseHoverEvent, NullEvent},
+ InputEvent, RenderInput, RenderState,
+};
#[derive(Debug, Clone)]
pub struct UiScrollbox {
@@ -52,16 +55,25 @@ impl UiScrollbox {
InputEvent::MouseMove(pos) => {
if r.contains_mouse(state, pos) && !self.has_mouse {
self.has_mouse = true;
+ return Some(Dynamic::from(MouseHoverEvent {
+ enter: true,
+ element: self.name.clone(),
+ }));
}
if !r.contains_mouse(state, pos) && self.has_mouse {
self.has_mouse = false;
+ return Some(Dynamic::from(MouseHoverEvent {
+ enter: false,
+ element: self.name.clone(),
+ }));
}
}
InputEvent::Scroll(x) => {
if self.has_mouse {
self.offset.y -= x;
+ return Some(Dynamic::from(NullEvent {}));
}
}
diff --git a/crates/render/src/ui/elements/textbox.rs b/crates/render/src/ui/elements/textbox.rs
index e845c1b..fa05609 100644
--- a/crates/render/src/ui/elements/textbox.rs
+++ b/crates/render/src/ui/elements/textbox.rs
@@ -3,17 +3,28 @@ use glyphon::{
Shaping, Style, TextBounds, Weight,
};
use nalgebra::Vector2;
-use rhai::ImmutableString;
+use rhai::{Dynamic, ImmutableString};
use std::rc::Rc;
use winit::window::Window;
use super::{super::api::Rect, OwnedTextArea};
-use crate::{ui::api, RenderInput};
+use crate::{
+ ui::api::{self, MouseHoverEvent, NullEvent},
+ InputEvent, RenderInput, RenderState,
+};
#[derive(Debug, Clone)]
pub struct UiTextBox {
pub name: ImmutableString,
+ /// Vertical scroll.
+ /// We don't use native cosmic_text scroll, since that only allows us
+ /// to scroll by individual lines.
+ v_offset: f32,
+
+ enable_scroll: bool,
+ has_mouse: bool,
+
text: String,
justify: Align,
rect: Rect,
@@ -34,7 +45,7 @@ impl UiTextBox {
let mut buffer = Buffer::new(font, Metrics::new(font_size, line_height));
// Do NOT apply UI scale here, that's only done when we make a TextArea
- buffer.set_size(font, rect.dim.x, rect.dim.y);
+ buffer.set_size(font, rect.dim.x, rect.dim.y + 100.0);
Self {
name,
@@ -44,6 +55,9 @@ impl UiTextBox {
justify: Align::Left,
attrs: AttrsOwned::new(Attrs::new()),
text: String::new(),
+ v_offset: 0.0,
+ has_mouse: false,
+ enable_scroll: false,
}
}
@@ -88,6 +102,83 @@ impl UiTextBox {
self.attrs.style = style;
self.reflow(font);
}
+
+ pub fn set_scroll(&mut self, scroll: bool) {
+ self.enable_scroll = scroll
+ }
+
+ pub fn reset_scroll(&mut self) {
+ self.v_offset = 0.0;
+ }
+
+ pub fn handle_event(
+ &mut self,
+ input: &RenderInput,
+ state: &mut RenderState,
+ event: &InputEvent,
+ ) -> Option {
+ self.handle_event_with_offset(input, state, event, Vector2::new(0.0, 0.0))
+ }
+
+ pub fn handle_event_with_offset(
+ &mut self,
+ input: &RenderInput,
+ state: &mut RenderState,
+ event: &InputEvent,
+ offset: Vector2,
+ ) -> Option {
+ let mut r = self
+ .rect
+ .to_centered(&state.window, input.ct.config.ui_scale);
+ r.pos += offset;
+
+ match event {
+ InputEvent::MouseMove(pos) => {
+ if r.contains_mouse(state, pos) && !self.has_mouse {
+ self.has_mouse = true;
+ return Some(Dynamic::from(MouseHoverEvent {
+ enter: true,
+ element: self.name.clone(),
+ }));
+ }
+
+ if !r.contains_mouse(state, pos) && self.has_mouse {
+ self.has_mouse = false;
+ return Some(Dynamic::from(MouseHoverEvent {
+ enter: false,
+ element: self.name.clone(),
+ }));
+ }
+ }
+
+ InputEvent::Scroll(x) => {
+ if self.has_mouse && self.enable_scroll {
+ self.v_offset -= x;
+
+ println!("{:?}", self.buffer.layout_runs().len());
+
+ let max_scroll = 0f32.max(
+ (self.buffer.metrics().line_height
+ * self.buffer.layout_runs().len() as f32)
+ - self.buffer.size().1,
+ );
+
+ // Top scroll bound
+ if self.v_offset <= 0.0 {
+ self.v_offset = 0.0;
+ } else if self.v_offset >= max_scroll {
+ self.v_offset = max_scroll;
+ }
+
+ return Some(Dynamic::from(NullEvent {}));
+ }
+ }
+
+ _ => return None,
+ }
+
+ return None;
+ }
}
impl<'a, 'b: 'a> UiTextBox {
@@ -106,6 +197,7 @@ impl<'a, 'b: 'a> UiTextBox {
// Glypon works with physical pixels, so we must do some conversion
let fac = window.scale_factor() as f32;
+ let scroll_offset = self.v_offset * fac;
let corner_ne = Vector2::new(
(rect.pos.x - rect.dim.x / 2.0) * fac + window.inner_size().width as f32 / 2.0,
window.inner_size().height as f32 / 2.0 - (rect.pos.y * fac + rect.dim.y / 2.0),
@@ -115,7 +207,7 @@ impl<'a, 'b: 'a> UiTextBox {
OwnedTextArea {
buffer: self.buffer.clone(),
- top: corner_ne.y,
+ top: corner_ne.y - scroll_offset,
left: corner_ne.x,
scale: input.ct.config.ui_scale,
bounds: TextBounds {
diff --git a/crates/render/src/ui/executor.rs b/crates/render/src/ui/executor.rs
index ba0a5dd..85282c5 100644
--- a/crates/render/src/ui/executor.rs
+++ b/crates/render/src/ui/executor.rs
@@ -8,7 +8,7 @@ use std::{cell::RefCell, num::NonZeroU32, rc::Rc, sync::Arc};
use winit::event::VirtualKeyCode;
use super::{
- api::{self, KeyboardEvent, PlayerShipStateEvent, ScrollEvent},
+ api::{self, KeyboardEvent, NullEvent, PlayerShipStateEvent, ScrollEvent},
UiConfig, UiElement, UiState,
};
use crate::{ui::api::State, InputEvent, RenderInput, RenderState};
@@ -75,8 +75,9 @@ impl UiScriptExecutor {
let arg = match ui_state.get_mut_by_idx(i).unwrap() {
UiElement::Sprite(sprite) => sprite.handle_event(&input, state, &event),
UiElement::Scrollbox(sbox) => sbox.handle_event(&input, state, &event),
+ UiElement::Text(text) => text.handle_event(&input, state, &event),
- UiElement::RadialBar(_) | UiElement::Text(..) => None,
+ UiElement::RadialBar(_) => None,
UiElement::SubElement { parent, element } => {
// Be very careful here, to avoid
// borrowing mutably twice...
@@ -94,7 +95,10 @@ impl UiScriptExecutor {
UiElement::Scrollbox(sbox) => {
sbox.handle_event_with_offset(&input, state, &event, offset)
}
- UiElement::RadialBar(_) | UiElement::Text(..) => None,
+ UiElement::Text(text) => {
+ text.handle_event_with_offset(&input, state, &event, offset)
+ }
+ UiElement::RadialBar(_) => None,
UiElement::SubElement { .. } => unreachable!(),
},
_ => unreachable!(),
@@ -109,6 +113,11 @@ impl UiScriptExecutor {
// Return if event hook returns any PlayerDirective (including None),
// continue to the next element if the hook returns ().
if let Some(arg) = arg {
+ // Ignore NullEvents
+ if arg.clone().try_cast::().is_some() {
+ return Ok(PlayerDirective::None);
+ }
+
let result = self.run_event_callback(state, &input, arg)?;
if let Some(result) = result {