2
0
Эх сурвалжийг харах

Sharing the scroll-bar implementation from ListBox with TextBox.

David Piuva 3 жил өмнө
parent
commit
2f52043cbe

+ 4 - 4
Source/DFPSR/api/mediaMachineAPI.cpp

@@ -1175,14 +1175,14 @@ String machine_getOutputName(MediaMachine& machine, int methodIndex, int outputI
 }
 
 MediaResult MediaMethod::callUsingKeywords(std::function<void(MediaMachine &machine, int methodIndex, int inputIndex, const ReadableString &argumentName)> setInputAction) {
-	if (methodIndex < 0 || methodIndex >= this->machine->methods.length()) {
-		throwError(U"Method index ", methodIndex, U" is out of bound 0..", this->machine->methods.length() - 1, U"\n");
+	if (this->methodIndex < 0 || this->methodIndex >= this->machine->methods.length()) {
+		throwError(U"Method index ", this->methodIndex, U" is out of bound 0..", this->machine->methods.length() - 1, U"\n");
 	}
-	Method *method = &(this->machine->methods[methodIndex]);
+	Method *method = &(this->machine->methods[this->methodIndex]);
 	int inputCount = method->inputCount;
 	// TODO: Make sure that input arguments are assigned default arguments before assigning inputs as keywords.
 	for (int i = 0; i < inputCount; i++) {
-		setInputAction(this->machine, methodIndex, i, method->locals[i].name);
+		setInputAction(this->machine, this->methodIndex, i, method->locals[i].name);
 	}
 	machine_executeMethod(this->machine, this->methodIndex);
 	return MediaResult(this->machine, this->methodIndex);

+ 3 - 3
Source/DFPSR/gui/VisualComponent.cpp

@@ -385,8 +385,8 @@ bool VisualComponent::isFocused() {
 	}
 }
 
-MediaResult VisualComponent::generateImage(MediaMethod &method, int width, int height, int red, int green, int blue, int pressed, int focused, int hover) {
-	return method.callUsingKeywords([this, &method, width, height, red, green, blue, pressed, focused, hover](MediaMachine &machine, int methodIndex, int inputIndex, const ReadableString &argumentName){
+MediaResult dsr::component_generateImage(VisualTheme theme, MediaMethod &method, int width, int height, int red, int green, int blue, int pressed, int focused, int hover) {
+	return method.callUsingKeywords([&theme, &method, width, height, red, green, blue, pressed, focused, hover](MediaMachine &machine, int methodIndex, int inputIndex, const ReadableString &argumentName){
 		if (string_caseInsensitiveMatch(argumentName, U"width")) {
 			machine_setInputByIndex(machine, methodIndex, inputIndex, width);
 		} else if (string_caseInsensitiveMatch(argumentName, U"height")) {
@@ -403,7 +403,7 @@ MediaResult VisualComponent::generateImage(MediaMethod &method, int width, int h
 			machine_setInputByIndex(machine, methodIndex, inputIndex, green);
 		} else if (string_caseInsensitiveMatch(argumentName, U"blue")) {
 			machine_setInputByIndex(machine, methodIndex, inputIndex, blue);
-		} else if (theme_assignMediaMachineArguments(this->theme, method.contextIndex, machine, methodIndex, inputIndex, argumentName)) {
+		} else if (theme_assignMediaMachineArguments(theme, method.contextIndex, machine, methodIndex, inputIndex, argumentName)) {
 			// Assigned by theme_assignMediaMachineArguments.
 		} else {
 			// TODO: Ask the theme for the argument using a specified style class for variations between different types of buttons, checkboxes, panels, et cetera.

+ 3 - 2
Source/DFPSR/gui/VisualComponent.h

@@ -34,6 +34,9 @@
 
 namespace dsr {
 
+// A reusable method for calling the media machine that allow providing additional variables as style flags.
+MediaResult component_generateImage(VisualTheme theme, MediaMethod &method, int width, int height, int red, int green, int blue, int pressed = 0, int focused = 0, int hover = 0);
+
 class VisualComponent : public Persistent {
 PERSISTENT_DECLARATION(VisualComponent)
 protected:
@@ -218,8 +221,6 @@ public:
 	// Returns true iff the component is focused.
 	//   The root component is considered focused if none of its children are focused.
 	bool isFocused();
-	// A reusable method for calling the media machine that allow providing additional variables as style flags.
-	MediaResult generateImage(MediaMethod &method, int width, int height, int red, int green, int blue, int pressed = 0, int focused = 0, int hover = 0);
 };
 
 }

+ 1 - 1
Source/DFPSR/gui/components/Button.cpp

@@ -56,7 +56,7 @@ bool Button::isContainer() const {
 static OrderedImageRgbaU8 generateButtonImage(Button &button, MediaMethod imageGenerator, int pressed, int width, int height, ColorRgbI32 backColor, ColorRgbI32 foreColor, String text, RasterFont font) {
 	// Create a scaled image
 	OrderedImageRgbaU8 result;
- 	button.generateImage(imageGenerator, width, height, backColor.red, backColor.green, backColor.blue, pressed)(result);
+ 	component_generateImage(button.getTheme(), imageGenerator, width, height, backColor.red, backColor.green, backColor.blue, pressed)(result);
 	if (string_length(text) > 0) {
 		int left = (image_getWidth(result) - font_getLineWidth(font, text)) / 2;
 		int top = (image_getHeight(result) - font_getSize(font)) / 2;

+ 46 - 162
Source/DFPSR/gui/components/ListBox.cpp

@@ -22,7 +22,6 @@
 //    distribution.
 
 #include "ListBox.h"
-#include <math.h>
 
 using namespace dsr;
 
@@ -58,22 +57,20 @@ bool ListBox::isContainer() const {
 
 static const int textBorderLeft = 6;
 static const int textBorderTop = 4;
-static const int scrollWidth = 16; // The width of the scroll bar
-static const int scrollEndHeight = 16; // The height of upper and lower scroll buttons
 
 void ListBox::generateGraphics() {
-	int width = this->location.width();
-	int height = this->location.height();
+	int32_t width = this->location.width();
+	int32_t height = this->location.height();
 	if (width < 1) { width = 1; }
 	if (height < 1) { height = 1; }
 	if (!this->hasImages) {
 		this->completeAssets();
 		ColorRgbI32 color = this->color.value;
-	 	this->generateImage(this->scalableImage_listBox, width, height, color.red, color.green, color.blue)(this->image);
-		int verticalStep = font_getSize(this->font);
-		int left = textBorderLeft;
-		int top = textBorderTop;
-		for (int64_t i = this->firstVisible; i < this->list.value.length() && top < height; i++) {
+	 	component_generateImage(this->theme, this->scalableImage_listBox, width, height, color.red, color.green, color.blue)(this->image);
+		int32_t verticalStep = font_getSize(this->font);
+		int32_t left = textBorderLeft;
+		int32_t top = textBorderTop;
+		for (int64_t i = this->verticalScrollBar.getValue(); i < this->list.value.length() && top < height; i++) {
 			ColorRgbaI32 textColor;
 			if (i == this->pressedIndex) {
 				textColor = ColorRgbaI32(255, 255, 255, 255);
@@ -88,29 +85,7 @@ void ListBox::generateGraphics() {
 			font_printLine(this->image, this->font, this->list.value[i], IVector2D(left, top), textColor);
 			top += verticalStep;
 		}
-		if (this->hasVerticalScroll) {
-			IRect whole = IRect(this->location.width() - scrollWidth, 0, scrollWidth, this->location.height());
-			IRect upper = IRect(whole.left(), whole.top(), whole.width(), scrollEndHeight);
-			IRect lower = IRect(whole.left(), whole.bottom() - scrollEndHeight, whole.width(), scrollEndHeight);
-			IRect knob = this->getKnobLocation();
-			// Only redraw the knob image if its dimensions changed
-			if (!image_exists(this->scrollKnobImage)
-			  || image_getWidth(this->scrollKnobImage) != knob.width()
-			  || image_getHeight(this->scrollKnobImage) != knob.height()) {
-				this->generateImage(this->scalableImage_verticalScrollKnob, knob.width(), knob.height(), color.red, color.green, color.blue, 0)(this->scrollKnobImage);
-			}
-			// Only redraw the scroll list if its dimenstions changed
-			if (!image_exists(this->verticalScrollBarImage)
-			  || image_getWidth(this->verticalScrollBarImage) != whole.width()
-			  || image_getHeight(this->verticalScrollBarImage) != whole.height()) {
-				this->generateImage(this->scalableImage_verticalScrollBackground, whole.width(), whole.height(), color.red, color.green, color.blue, 0)(this->verticalScrollBarImage);
-			}
-			// Draw the scroll-bar
-			draw_alphaFilter(this->image, this->verticalScrollBarImage, whole.left(), whole.top());
-			draw_alphaFilter(this->image, this->scrollKnobImage, knob.left(), knob.top());
-			draw_alphaFilter(this->image, this->pressScrollUp ? this->scrollButtonTopImage_pressed : this->scrollButtonTopImage_normal, upper.left(), upper.top());
-			draw_alphaFilter(this->image, this->pressScrollDown ? this->scrollButtonBottomImage_pressed : this->scrollButtonBottomImage_normal, lower.left(), lower.top());
-		}
+		this->verticalScrollBar.draw(this->image, this->theme, color);
 		this->hasImages = true;
 	}
 }
@@ -120,93 +95,67 @@ void ListBox::drawSelf(ImageRgbaU8& targetImage, const IRect &relativeLocation)
 	draw_copy(targetImage, this->image, relativeLocation.left(), relativeLocation.top());
 }
 
-void ListBox::pressScrollBar(int64_t localY) {
-	int64_t oldIndex = this->firstVisible;
-	int64_t maxScroll = this->list.value.length() - this->getVisibleScrollRange();
-	int64_t knobHeight = this->getKnobLocation().height();
-	int64_t endDistance = scrollEndHeight + knobHeight / 2;
-	int64_t barHeight = this->location.height() - (endDistance * 2);
-	this->firstVisible = ((localY - endDistance) * maxScroll + (barHeight / 2)) / barHeight;
-	this->limitScrolling();
-	// Avoid expensive redrawing if the index did not change
-	if (this->firstVisible != oldIndex) {
-		this->hasImages = false; // Force redraw
-	}
+void ListBox::updateScrollRange() {
+	this->loadFont();
+	// How high is one element?
+	int64_t verticalStep = font_getSize(this->font);
+	// How many elements are visible at the same time?
+	int64_t visibleRange = (this->location.height() - textBorderTop * 2) / verticalStep;
+	if (visibleRange < 1) visibleRange = 1;
+	// How many elements are there in total to see.
+	int64_t itemCount = this->list.value.length();
+	// The range of indices that the listbox can start viewing from.
+	int64_t minScroll = 0;
+	int64_t maxScroll = itemCount - visibleRange;
+	// If visible range exceeds the collection, we should still allow starting element zero to get a valid range.
+	if (maxScroll < 0) maxScroll = 0;
+	// Apply the scroll range.
+	this->verticalScrollBar.updateScrollRange(ScrollRange(minScroll, maxScroll, visibleRange));
+}
+
+void ListBox::limitScrolling(bool keepSelectedVisible) {
+	// Update the scroll range.
+	this->updateScrollRange();
+	// Limit scrolling with the updated range.
+	this->verticalScrollBar.limitScrolling(this->location, keepSelectedVisible, this->selectedIndex.value);
 }
 
 void ListBox::receiveMouseEvent(const MouseEvent& event) {
 	bool supressEvent = false;
 	this->inside = this->pointIsInside(event.position);
 	IVector2D localPosition = event.position - this->location.upperLeft();
-	bool onScrollBar = this->hasVerticalScroll && localPosition.x >= this->location.width() - scrollWidth;
+	bool verticalScrollIntercepted = this->verticalScrollBar.receiveMouseEvent(this->location, event);
 	int64_t maxIndex = this->list.value.length() - 1;
-	int64_t hoverIndex = this->firstVisible + ((localPosition.y - textBorderTop) / font_getSize(this->font));
-	if (hoverIndex > maxIndex) {
+	int64_t hoverIndex = this->verticalScrollBar.getValue() + ((localPosition.y - textBorderTop) / font_getSize(this->font));
+	if (hoverIndex < 0 || hoverIndex > maxIndex) {
 		hoverIndex = -1;
 	}
 	if (event.mouseEventType == MouseEventType::MouseDown) {
-		if (onScrollBar) {
+		if (verticalScrollIntercepted) {
 			this->pressedIndex = -1;
-			if (localPosition.y < scrollEndHeight) {
-				// Upper scroll button
-				this->pressScrollUp = true;
-				this->firstVisible--;
-			} else if (localPosition.y > this->location.height() - scrollEndHeight) {
-				// Lower scroll button
-				this->pressScrollDown = true;
-				this->firstVisible++;
-			} else {
-				// Start scrolling with the mouse using the relative height on the scroll bar.
-				IRect knobLocation = this->getKnobLocation();
-				int64_t halfKnobHeight = knobLocation.height() / 2;
-				this->knobHoldOffset = localPosition.y - (knobLocation.top() + halfKnobHeight);
-				if (this->knobHoldOffset < -halfKnobHeight || this->knobHoldOffset > halfKnobHeight) {
-					// If pressing outside of the knob, pull it directly to the pressed location before pulling from the center.
-					this->knobHoldOffset = 0;
-					this->pressScrollBar(localPosition.y - this->knobHoldOffset);
-				}
-				this->holdingScrollBar = true;
-			}
 		} else {
 			this->pressedIndex = hoverIndex;
 		}
-		this->limitScrolling();
-		this->hasImages = false; // Force redraw
+		this->hasImages = false; // Force redraw on item selection
 	} else if (event.mouseEventType == MouseEventType::MouseUp) {
-		if (this->pressedIndex > -1 && this->inside && !onScrollBar && hoverIndex == this->pressedIndex) {
+		if (this->pressedIndex > -1 && this->inside && hoverIndex == this->pressedIndex) {
 			this->setSelectedIndex(hoverIndex, false);
 			this->limitScrolling(true);
 			this->callback_pressedEvent();
 		}
-		this->pressScrollUp = false;
-		this->pressScrollDown = false;
 		this->pressedIndex = -1;
-		this->holdingScrollBar = false;
-		this->hasImages = false; // Force redraw
-	} else if (event.mouseEventType == MouseEventType::Scroll) {
-		if (event.key == MouseKeyEnum::ScrollUp) {
-			this->firstVisible--;
-		} else if (event.key == MouseKeyEnum::ScrollDown) {
-			this->firstVisible++;
-		}
-		this->holdingScrollBar = false;
-		this->limitScrolling();
-		this->hasImages = false; // Force redraw
-	} else if (event.mouseEventType == MouseEventType::MouseMove) {
-		if (this->holdingScrollBar) {
-			supressEvent = true;
-			this->pressScrollBar(localPosition.y - this->knobHoldOffset);
-		}
 	}
-	if (!supressEvent) {
+	if (verticalScrollIntercepted) {
+		this->hasImages = false; // Force redraw on scrollbar interception
+	} else {
 		VisualComponent::receiveMouseEvent(event);
 	}
 }
 
 void ListBox::receiveKeyboardEvent(const KeyboardEvent& event) {
-	if (event.keyboardEventType == KeyboardEventType::KeyDown) {
-		int contentLength = this->list.value.length();
-		int oldIndex = this->selectedIndex.value;
+	if (event.keyboardEventType == KeyboardEventType::KeyType) {
+		int64_t contentLength = this->list.value.length();
+		int64_t oldIndex = this->selectedIndex.value;
 		if (contentLength > 1) {
 			if (oldIndex > 0 && event.dsrKey == DsrKey::DsrKey_UpArrow) {
 				this->setSelectedIndex(oldIndex - 1, true);
@@ -220,16 +169,7 @@ void ListBox::receiveKeyboardEvent(const KeyboardEvent& event) {
 
 void ListBox::loadTheme(VisualTheme theme) {
 	this->scalableImage_listBox = theme_getScalableImage(theme, U"ListBox");
-	this->scalableImage_scrollTop = theme_getScalableImage(theme, U"ScrollUp");
-	this->scalableImage_scrollBottom = theme_getScalableImage(theme, U"ScrollDown");
-	this->scalableImage_verticalScrollKnob = theme_getScalableImage(theme, U"VerticalScrollKnob");
-	this->scalableImage_verticalScrollBackground = theme_getScalableImage(theme, U"VerticalScrollList");
-	// Generate fixed size buttons for the scroll buttons (because their size is currently given by constants)
-	ColorRgbI32 color = this->color.value;
-	this->generateImage(this->scalableImage_scrollTop,    scrollWidth, scrollEndHeight, color.red, color.green, color.blue, 0)(this->scrollButtonTopImage_normal);
-	this->generateImage(this->scalableImage_scrollTop,    scrollWidth, scrollEndHeight, color.red, color.green, color.blue, 1)(this->scrollButtonTopImage_pressed);	
-	this->generateImage(this->scalableImage_scrollBottom, scrollWidth, scrollEndHeight, color.red, color.green, color.blue, 0)(this->scrollButtonBottomImage_normal);
-	this->generateImage(this->scalableImage_scrollBottom, scrollWidth, scrollEndHeight, color.red, color.green, color.blue, 1)(this->scrollButtonBottomImage_pressed);
+	this->verticalScrollBar.loadTheme(theme, this->color.value);
 }
 
 void ListBox::changedTheme(VisualTheme newTheme) {
@@ -273,7 +213,7 @@ void ListBox::changedAttribute(const ReadableString &name) {
 	this->limitScrolling();
 }
 
-void ListBox::setSelectedIndex(int index, bool forceUpdate) {
+void ListBox::setSelectedIndex(int64_t index, bool forceUpdate) {
 	if (forceUpdate || this->selectedIndex.value != index) {
 		this->selectedIndex.value = index;
 		this->hasImages = false;
@@ -302,62 +242,6 @@ void ListBox::limitSelection(bool indexChangedMeaning) {
 	}
 }
 
-int64_t ListBox::getVisibleScrollRange() {
-	this->loadFont(); // We might not have the color assigned yet, but at least load the font to get the item height.
-	int64_t verticalStep = font_getSize(this->font);
-	return (this->location.height() - textBorderTop * 2) / verticalStep;
-}
-
-IRect ListBox::getScrollBarLocation_excludingButtons() {
-	return IRect(this->location.width() - scrollWidth, scrollEndHeight, scrollWidth, this->location.height() - (scrollEndHeight * 2));
-}
-
-IRect ListBox::getKnobLocation() {
-	// Eroded scroll-bar excluding buttons
-	// The final knob is a sub-set of this region corresponding to the visibility
-	IRect scrollBarRegion = this->getScrollBarLocation_excludingButtons();
-	// Item ranges
-	int64_t visibleRange = this->getVisibleScrollRange(); // 0..visibleRange-1
-	int64_t itemCount = this->list.value.length(); // 0..itemCount-1
-	int64_t maxScroll = itemCount - visibleRange; // 0..maxScroll
-	// Dimensions
-	int64_t knobHeight = (scrollBarRegion.height() * visibleRange) / itemCount;
-	if (knobHeight < scrollBarRegion.width()) {
-		knobHeight = scrollBarRegion.width();
-	}
-	// Visual range for center
-	int64_t scrollStart = scrollBarRegion.top() + knobHeight / 2;
-	int64_t scrollDistance = scrollBarRegion.height() - knobHeight;
-	int64_t knobCenterY = scrollStart + ((this->firstVisible * scrollDistance) / maxScroll);
-	return IRect(scrollBarRegion.left(), knobCenterY - (knobHeight / 2), scrollBarRegion.width(), knobHeight);
-}
-
-// Optional limit of scrolling, to be applied when the user don't explicitly scroll away from the selection
-// limitSelection should be called before limitScrolling, because scrolling limits depend on selection
-void ListBox::limitScrolling(bool keepSelectedVisible) {
-	// Try to load the font before estimating how big the view is
-	this->loadFont();
-	int64_t itemCount = this->list.value.length();
-	int64_t visibleRange = this->getVisibleScrollRange();
-	int64_t maxScroll;
-	int64_t minScroll;
-	// Big enough list to need scrolling but big enough list-box to fit two buttons inside
-	this->hasVerticalScroll = itemCount > visibleRange && this->location.width() >= scrollWidth * 2 && this->location.height() >= scrollEndHeight * 3;
-	if (keepSelectedVisible) {
-		maxScroll = this->selectedIndex.value;
-		minScroll = maxScroll + 1 - visibleRange;
-	} else {
-		maxScroll = itemCount - visibleRange;
-		minScroll = 0;
-	}
-	if (this->firstVisible > maxScroll) {
-		this->firstVisible = maxScroll;
-	}
-	if (this->firstVisible < minScroll) {
-		this->firstVisible = minScroll;
-	}
-}
-
 String ListBox::call(const ReadableString &methodName, const ReadableString &arguments) {
 	if (string_caseInsensitiveMatch(methodName, U"ClearAll")) {
 		// Remove all elements from the list
@@ -365,7 +249,7 @@ String ListBox::call(const ReadableString &methodName, const ReadableString &arg
 		this->hasImages = false;
 		this->selectedIndex.value = 0;
 		this->limitScrolling();
-		this->firstVisible = 0;
+		this->verticalScrollBar.setValue(0);
 		return U"";
 	} else if (string_caseInsensitiveMatch(methodName, U"PushElement")) {
 		// Push a new element to the list

+ 5 - 14
Source/DFPSR/gui/components/ListBox.h

@@ -25,6 +25,7 @@
 #define DFPSR_GUI_COMPONENT_LISTBOX
 
 #include "../VisualComponent.h"
+#include "helpers/ScrollBarImpl.h"
 #include "../../api/fontAPI.h"
 
 namespace dsr {
@@ -39,41 +40,31 @@ public:
 	void declareAttributes(StructureDefinition &target) const override;
 	Persistent* findAttribute(const ReadableString &name) override;
 private:
+	// Value allocated sub-components
+	ScrollBarImpl verticalScrollBar = ScrollBarImpl(true);
 	// Temporary
-	bool pressScrollUp = false;
-	bool pressScrollDown = false;
-	bool holdingScrollBar = false;
-	int64_t knobHoldOffset = 0; // The number of pixels down from the center that the knob grabbed last time.
 	bool inside = false;
-	bool hasVerticalScroll = false;
 	int64_t pressedIndex = -1; // Index of pressed item or -1 for none.
-	int64_t firstVisible = 0; // Index of first visible element for scrolling. May never go below zero.
 	// Given from the style
 	MediaMethod scalableImage_listBox;
-	MediaMethod scalableImage_scrollTop, scalableImage_scrollBottom;
-	MediaMethod scalableImage_verticalScrollBackground, scalableImage_verticalScrollKnob;
 	RasterFont font;
 	void loadFont();
 	void completeAssets();
 	void generateGraphics();
 	// Generated
 	bool hasImages = false;
-	OrderedImageRgbaU8 scrollButtonTopImage_normal, scrollButtonTopImage_pressed, scrollButtonBottomImage_normal, scrollButtonBottomImage_pressed;
-	OrderedImageRgbaU8 scrollKnobImage, verticalScrollBarImage;
 	OrderedImageRgbaU8 image;
 	// Helper methods
 	// Returns the selected index referring to an existing element or -1 if none is selected
 	int64_t getSelectedIndex();
 	void limitSelection(bool indexChangedMeaning); // Clamp selection to valid range
+	void updateScrollRange(); // Calculate range for scrollbar
 	void limitScrolling(bool keepSelectedVisible = false); // Clamp scrolling
 	int64_t getVisibleScrollRange(); // Return the number of items that are visible at once
-	IRect getScrollBarLocation_includingButtons(); // Return the location of the scroll-bar with buttons
-	IRect getScrollBarLocation_excludingButtons(); // Return the location of the scroll-bar without buttons
-	IRect getKnobLocation(); // Return the location of the scroll-bar's knob
 	void pressScrollBar(int64_t localY); // Press the scroll-bar at localY in pixels
 	void loadTheme(VisualTheme theme);
 	// If a new selection inherited the old index, forceUpdate will send the select event anyway
-	void setSelectedIndex(int index, bool forceUpdate);
+	void setSelectedIndex(int64_t index, bool forceUpdate);
 public:
 	ListBox();
 public:

+ 1 - 1
Source/DFPSR/gui/components/Panel.cpp

@@ -57,7 +57,7 @@ void Panel::generateGraphics() {
 	if (height < 1) { height = 1; }
 	if (!this->hasImages) {
 		completeAssets();
-		this->generateImage(this->background, width, height, this->color.value.red, this->color.value.green, this->color.value.blue)(this->imageBackground);
+		component_generateImage(this->theme, this->background, width, height, this->color.value.red, this->color.value.green, this->color.value.blue)(this->imageBackground);
 		this->hasImages = true;
 	}
 }

+ 186 - 85
Source/DFPSR/gui/components/TextBox.cpp

@@ -22,7 +22,6 @@
 //    distribution.
 
 #include "TextBox.h"
-#include <math.h>
 #include <functional>
 
 using namespace dsr;
@@ -66,20 +65,22 @@ void TextBox::limitSelection() {
 	if (this->beamLocation > textLength) this->beamLocation = textLength;
 }
 
-static void tabJump(int64_t &x, int64_t leftOrigin, int64_t tabWidth) {
-	x += tabWidth - ((x - leftOrigin) % tabWidth);
+static void tabJump(int64_t &x, int64_t tabWidth) {
+	x += tabWidth - (x % tabWidth);
 }
 
-// To have a stable tab alignment, the whole text must be given when iterating.
-void iterateCharacters(const ReadableString& text, const RasterFont &font, int64_t originX, std::function<void(int64_t index, DsrChar code, int64_t left, int64_t right)> characterAction) {
-	int64_t right = originX;
-	int64_t tabWidth = font_getTabWidth(font);
+static int64_t monospacesPerTab = 4;
+
+// Pre-condition: text does not contain any linebreak.
+void iterateCharactersInLine(const ReadableString& text, const RasterFont &font, std::function<void(int64_t index, DsrChar code, int64_t left, int64_t right)> characterAction) {
+	int64_t right = 0;
 	int64_t monospaceWidth = font_getMonospaceWidth(font);
+	int64_t tabWidth = monospaceWidth * monospacesPerTab;
 	for (int64_t i = 0; i <= string_length(text); i++) {
 		DsrChar code = text[i];
 		int64_t left = right;
 		if (code == U'\t') {
-			tabJump(right, originX, tabWidth);
+			tabJump(right, tabWidth);
 		} else {
 			right += monospaceWidth;
 		}
@@ -88,11 +89,13 @@ void iterateCharacters(const ReadableString& text, const RasterFont &font, int64
 }
 
 // Iterate over the whole text once for both selection and characters.
-// Returns the beam's X location in pixels relative to the parent of originX.
+// Returns the beam's X location in pixels.
 int64_t printMonospaceLine(OrderedImageRgbaU8 &target, const ReadableString& text, const RasterFont &font, ColorRgbaI32 foreColor, bool focused, int64_t originX, int64_t selectionLeft, int64_t selectionRight, int64_t beamIndex, int64_t topY, int64_t bottomY) {
 	int64_t characterHeight = bottomY - topY;
-	int64_t beamPixelX = originX;
-	iterateCharacters(text, font, originX, [&target, &font, &foreColor, &beamPixelX, selectionLeft, selectionRight, beamIndex, topY, characterHeight, focused](int64_t index, DsrChar code, int64_t left, int64_t right) {
+	int64_t beamPixelX = 0;
+	iterateCharactersInLine(text, font, [&target, &font, &foreColor, &beamPixelX, originX, selectionLeft, selectionRight, beamIndex, topY, characterHeight, focused](int64_t index, DsrChar code, int64_t left, int64_t right) {
+		left += originX;
+		right += originX;
 		if (index == beamIndex) beamPixelX = left;
 		if (focused && selectionLeft <= index && index < selectionRight) {
 			draw_rectangle(target, IRect(left, topY, right - left, characterHeight), ColorRgbaI32(0, 0, 100, 255));
@@ -104,26 +107,47 @@ int64_t printMonospaceLine(OrderedImageRgbaU8 &target, const ReadableString& tex
 	return beamPixelX;
 }
 
-void TextBox::updateLines() {
-	// Index the lines for fast scrolling and rendering.
-	this->lines.clear();
-	int64_t sectionStart = 0;
-	int64_t textLength = string_length(this->text.value);
-	for (int64_t i = 0; i < textLength; i++) {
-		if (this->text.value[i] == U'\n') {
-			this->lines.pushConstruct(sectionStart, i);
-			sectionStart = i + 1;
+void TextBox::indexLines() {
+	int64_t newLength = string_length(this->text.value);
+	if (newLength != this->indexedAtLength) {
+		int64_t currentLength = 0;
+		int64_t worstCaseLength = 0;
+		// Index the lines for fast scrolling and rendering.
+		this->lines.clear();
+		int64_t sectionStart = 0;
+		for (int64_t i = 0; i <= newLength; i++) {
+			DsrChar c = this->text.value[i];
+			if (c == U'\n' || c == U'\0') {
+				if (currentLength > worstCaseLength) {
+					worstCaseLength = currentLength;
+				}
+				currentLength = 0;
+				this->lines.pushConstruct(sectionStart, i);
+				sectionStart = i + 1;
+			} else if (c == U'\t') {
+				currentLength += 4;
+			} else {
+				currentLength += 1;
+			}
 		}
+		this->indexedAtLength = newLength;
+		this->worstCaseLineMonospaces = worstCaseLength;
 	}
-	// Always include the line after a linebreak, even if it is empty.
-	this->lines.pushConstruct(sectionStart, textLength);
 }
 
-LVector2D TextBox::getTextOrigin() {
+LVector2D TextBox::getTextOrigin(bool includeVerticalScroll) {
 	int64_t rowStride = font_getSize(this->font);
-	int64_t halfRowStride = rowStride / 2;
-	int64_t firstVisibleLine = this->verticalScroll / rowStride;
-	return LVector2D(halfRowStride - this->horizontalScroll, this->multiLine.value ? (halfRowStride + (firstVisibleLine * rowStride) - this->verticalScroll) : ((image_getHeight(this->image) / 2) - halfRowStride));
+	int64_t offsetX = this->borderX - this->horizontalScrollBar.getValue();
+	int64_t offsetY = 0;
+	if (this->multiLine.value) {
+		offsetY = this->borderY;
+	} else {
+		offsetY = (image_getHeight(this->image) - rowStride) / 2;
+	}
+	if (includeVerticalScroll) {
+		offsetY -= this->verticalScrollBar.getValue() * rowStride;
+	}
+	return LVector2D(offsetX, offsetY);
 }
 
 // TODO: Reuse scaled background images as a separate layer.
@@ -138,29 +162,24 @@ LVector2D TextBox::getTextOrigin() {
 //         If no color is assigned, the class will give it a standard color from the theme.
 //         Should classes be separate for themes and palettes?
 void TextBox::generateGraphics() {
-	int width = this->location.width();
-	int height = this->location.height();
+	int32_t width = this->location.width();
+	int32_t height = this->location.height();
 	if (width < 1) { width = 1; }
 	if (height < 1) { height = 1; }
 	bool focused = this->isFocused();
-	if (!this->indexedLines) {
-		this->updateLines();
-		this->indexedLines = true;
-	}
 	if (!this->hasImages || this->drawnAsFocused != focused) {
 		this->hasImages = true;
 		this->drawnAsFocused = focused;
 		completeAssets();
-		ColorRgbaI32 backColor = ColorRgbaI32(this->backColor.value, 255);
-		ColorRgbaI32 foreColor = ColorRgbaI32(this->foreColor.value, 255);
-
+		this->indexLines();
+		ColorRgbaI32 foreColorRgba = ColorRgbaI32(this->foreColor.value, 255);
 		// Create a scaled image
-		this->generateImage(this->textBox, width, height, backColor.red, backColor.green, backColor.blue, 0, focused ? 1 : 0)(this->image);
+		component_generateImage(this->theme, this->textBox, width, height, this->backColor.value.red, this->backColor.value.green, this->backColor.value.blue, 0, focused ? 1 : 0)(this->image);
 		this->limitSelection();
-		LVector2D origin = this->getTextOrigin();
+		LVector2D origin = this->getTextOrigin(false);
 		int64_t rowStride = font_getSize(this->font);
 		int64_t targetHeight = image_getHeight(this->image);
-		int64_t firstVisibleLine = this->verticalScroll / rowStride;
+		int64_t firstVisibleLine = this->verticalScrollBar.getValue();
 
 		// Find character indices for left and right sides.
 		int64_t selectionLeft = std::min(this->selectionStart, this->beamLocation);
@@ -169,18 +188,20 @@ void TextBox::generateGraphics() {
 
 		// Draw the text with selection and get the beam's pixel location.
 		int64_t topY = origin.y;
-		for (int row = firstVisibleLine; row < this->lines.length() && topY < targetHeight; row++) {
+		for (int64_t row = firstVisibleLine; row < this->lines.length() && topY < targetHeight; row++) {
 			int64_t startIndex = this->lines[row].lineStartIndex;
 			int64_t endIndex = this->lines[row].lineEndIndex;
 			ReadableString currentLine = string_exclusiveRange(this->text.value, startIndex, endIndex);
-			int64_t beamPixelX = printMonospaceLine(this->image, currentLine, this->font, foreColor, focused, origin.x, selectionLeft - startIndex, selectionRight - startIndex, this->beamLocation - startIndex, topY, topY + rowStride);
+			int64_t beamPixelX = printMonospaceLine(this->image, currentLine, this->font, foreColorRgba, focused, origin.x, selectionLeft - startIndex, selectionRight - startIndex, this->beamLocation - startIndex, topY, topY + rowStride);
 			// Draw a beam if the textbox is focused.
 			if (focused && this->beamLocation >= startIndex && this->beamLocation <= endIndex) {
 				int64_t beamWidth = 2;
-				draw_rectangle(this->image, IRect(beamPixelX - 1, topY - 1, beamWidth, rowStride + 2), hasSelection ? ColorRgbaI32(255, 255, 255, 255) : foreColor);
+				draw_rectangle(this->image, IRect(beamPixelX - 1, topY - 1, beamWidth, rowStride + 2), hasSelection ? ColorRgbaI32(255, 255, 255, 255) : foreColorRgba);
 			}
 			topY += rowStride;
 		}
+		this->verticalScrollBar.draw(this->image, this->theme, this->backColor.value);
+		this->horizontalScrollBar.draw(this->image, this->theme, this->backColor.value);
 	}
 }
 
@@ -190,7 +211,7 @@ void TextBox::drawSelf(ImageRgbaU8& targetImage, const IRect &relativeLocation)
 }
 
 int64_t TextBox::findBeamLocationInLine(int64_t rowIndex, int64_t pixelX) {
-	LVector2D origin = this->getTextOrigin();
+	LVector2D origin = this->getTextOrigin(true);
 	// Clamp to the closest row if going outside.
 	if (rowIndex < 0) rowIndex = 0;
 	if (rowIndex >= this->lines.length()) rowIndex = this->lines.length() - 1;
@@ -199,8 +220,8 @@ int64_t TextBox::findBeamLocationInLine(int64_t rowIndex, int64_t pixelX) {
 	int64_t startIndex = this->lines[rowIndex].lineStartIndex;
 	int64_t endIndex = this->lines[rowIndex].lineEndIndex;
 	ReadableString currentLine = string_exclusiveRange(this->text.value, startIndex, endIndex);
-	iterateCharacters(currentLine, font, origin.x, [&beamIndex, &closestDistance, pixelX](int64_t index, DsrChar code, int64_t left, int64_t right) {
-		int64_t center = (left + right) / 2;
+	iterateCharactersInLine(currentLine, font, [&beamIndex, &closestDistance, &origin, pixelX](int64_t index, DsrChar code, int64_t left, int64_t right) {
+		int64_t center = origin.x + (left + right) / 2;
 		int64_t newDistance = std::abs(pixelX - center);
 		if (newDistance < closestDistance) {
 			beamIndex = index;
@@ -210,45 +231,79 @@ int64_t TextBox::findBeamLocationInLine(int64_t rowIndex, int64_t pixelX) {
 	return startIndex + beamIndex;
 }
 
-int64_t TextBox::findBeamLocation(const LVector2D &pixelLocation) {
-	LVector2D origin = this->getTextOrigin();
+BeamLocation TextBox::findBeamLocation(const LVector2D &pixelLocation) {
+	LVector2D origin = this->getTextOrigin(true);
 	int64_t rowStride = font_getSize(this->font);
 	int64_t rowIndex = (pixelLocation.y - origin.y) / rowStride;
-	return this->findBeamLocationInLine(rowIndex, pixelLocation.x);
+	return BeamLocation(rowIndex, this->findBeamLocationInLine(rowIndex, pixelLocation.x));
+}
+
+static int64_t findBeamRow(List<LineIndex> lines, int64_t beamLocation) {
+	int64_t result = 0;
+	for (int64_t row = 0; row < lines.length(); row++) {
+		int64_t startIndex = lines[row].lineStartIndex;
+		int64_t endIndex = lines[row].lineEndIndex;
+		if (beamLocation >= startIndex && beamLocation <= endIndex) {
+			result = row;
+		}
+	}
+	return result;
+}
+
+// Returns the beam's pixel offset relative to the origin.
+static int64_t getBeamPixelOffset(const ReadableString &text, const RasterFont &font, List<LineIndex> lines, const BeamLocation &beam) {
+	int64_t result = 0;
+	int64_t lineStartIndex = lines[beam.rowIndex].lineStartIndex;
+	int64_t lineEndIndex = lines[beam.rowIndex].lineEndIndex;
+	int64_t localBeamIndex = beam.characterIndex - lineStartIndex;
+	ReadableString currentLine = string_exclusiveRange(text, lineStartIndex, lineEndIndex);
+	iterateCharactersInLine(currentLine, font, [&result, localBeamIndex](int64_t index, DsrChar code, int64_t left, int64_t right) {
+		if (index == localBeamIndex) result = left;
+	});
+	return result;
 }
 
 void TextBox::receiveMouseEvent(const MouseEvent& event) {
-	if (event.mouseEventType == MouseEventType::MouseDown) {
+	bool verticalScrollIntercepted = this->verticalScrollBar.receiveMouseEvent(this->location, event);
+	bool horizontalScrollIntercepted = this->horizontalScrollBar.receiveMouseEvent(this->location, event);
+	bool scrollIntercepted = verticalScrollIntercepted || horizontalScrollIntercepted;
+	if (event.mouseEventType == MouseEventType::MouseDown && !scrollIntercepted) {
 		this->mousePressed = true;
-		int64_t newBeamIndex = findBeamLocation(LVector2D(event.position.x - this->location.left(), event.position.y - this->location.top()));
-		if (newBeamIndex != this->selectionStart || newBeamIndex != this->beamLocation) {
-			this->selectionStart = newBeamIndex;
-			this->beamLocation = newBeamIndex;
+		BeamLocation newBeam = findBeamLocation(LVector2D(event.position.x - this->location.left(), event.position.y - this->location.top()));
+		if (newBeam.characterIndex != this->selectionStart || newBeam.characterIndex != this->beamLocation) {
+			this->selectionStart = newBeam.characterIndex;
+			this->beamLocation = newBeam.characterIndex;
 			this->hasImages = false;
 		}
 	} else if (this->mousePressed && event.mouseEventType == MouseEventType::MouseMove) {
 		if (this->mousePressed) {
-			int64_t newBeamIndex = findBeamLocation(LVector2D(event.position.x - this->location.left(), event.position.y - this->location.top()));
-			if (newBeamIndex != this->beamLocation) {
-				this->beamLocation = newBeamIndex;
+			BeamLocation newBeam = findBeamLocation(LVector2D(event.position.x - this->location.left(), event.position.y - this->location.top()));
+			if (newBeam.characterIndex != this->beamLocation) {
+				this->beamLocation = newBeam.characterIndex;
 				this->hasImages = false;
 			}
 		}
 	} else if (this->mousePressed && event.mouseEventType == MouseEventType::MouseUp) {
 		this->mousePressed = false;
 	}
-	VisualComponent::receiveMouseEvent(event);
+	if (scrollIntercepted) {
+		this->hasImages = false; // Force redraw on scrollbar interception
+	} else {
+		VisualComponent::receiveMouseEvent(event);
+	}
 }
 
-void TextBox::replaceSelection(const ReadableString replacingText) {
+void TextBox::replaceSelection(const ReadableString &replacingText) {
 	int64_t selectionLeft = std::min(this->selectionStart, this->beamLocation);
 	int64_t selectionRight = std::max(this->selectionStart, this->beamLocation);
 	this->text.value = string_combine(string_before(this->text.value, selectionLeft), replacingText, string_from(this->text.value, selectionRight));
 	// Place beam on the right side of the replacement without selecting anything
 	this->selectionStart = selectionLeft + string_length(replacingText);
 	this->beamLocation = selectionStart;
-	this->indexedLines = false;
 	this->hasImages = false;
+	this->indexedAtLength = -1;
+	this->indexLines();
+	this->limitScrolling(true);
 }
 
 void TextBox::replaceSelection(DsrChar replacingCharacter) {
@@ -261,8 +316,10 @@ void TextBox::replaceSelection(DsrChar replacingCharacter) {
 	// Place beam on the right side of the replacement without selecting anything
 	this->selectionStart = selectionLeft + 1;
 	this->beamLocation = selectionStart;
-	this->indexedLines = false;
 	this->hasImages = false;
+	this->indexedAtLength = -1;
+	this->indexLines();
+	this->limitScrolling(true);
 }
 
 void TextBox::placeBeamAtCharacter(int64_t characterIndex, bool removeSelection) {
@@ -271,37 +328,24 @@ void TextBox::placeBeamAtCharacter(int64_t characterIndex, bool removeSelection)
 		this->selectionStart = characterIndex;
 	}
 	this->hasImages = false;
+	this->limitScrolling(true);
 }
 
 void TextBox::moveBeamVertically(int64_t rowIndexOffset, bool removeSelection) {
 	// Find the current beam's row index.
-	int64_t oldRowIndex = 0;
-	for (int row = 0; row < this->lines.length(); row++) {
-		int64_t startIndex = this->lines[row].lineStartIndex;
-		int64_t endIndex = this->lines[row].lineEndIndex;
-		if (this->beamLocation >= startIndex && this->beamLocation <= endIndex) {
-			oldRowIndex = row;
-		}
-	}
+	int64_t oldRowIndex = findBeamRow(this->lines, this->beamLocation);
 	// Find another row.
 	int64_t newRowIndex = oldRowIndex + rowIndexOffset;
 	if (newRowIndex < 0) { newRowIndex = 0; }
 	if (newRowIndex >= this->lines.length()) { newRowIndex = this->lines.length() - 1; }
-	// Get the old pixel offset from the beam.
-	LVector2D origin = this->getTextOrigin();
-	int64_t beamPixelX = 0;
-	int64_t lineStartIndex = this->lines[oldRowIndex].lineStartIndex;
-	int64_t lineEndIndex = this->lines[oldRowIndex].lineEndIndex;
-	int64_t localBeamIndex = this->beamLocation - lineStartIndex;
-	ReadableString currentLine = string_exclusiveRange(this->text.value, lineStartIndex, lineEndIndex);
-	iterateCharacters(currentLine, font, origin.x, [&beamPixelX, localBeamIndex](int64_t index, DsrChar code, int64_t left, int64_t right) {
-		if (index == localBeamIndex) beamPixelX = left;
-	});
-	printText(U"beamPixelX = ", beamPixelX, U"\n");
+	// Get old pixel offset from the beam.
+	LVector2D origin = this->getTextOrigin(true);
+	BeamLocation oldBeam = BeamLocation(oldRowIndex, this->beamLocation);
+	int64_t oldPixelOffset = origin.x + getBeamPixelOffset(this->text.value, this->font, this->lines, oldBeam);
 	// Get the closest location in the new row.
-	int64_t newCharacterIndex = findBeamLocationInLine(newRowIndex, beamPixelX);
-	printText(U"newCharacterIndex = ", newCharacterIndex, U"\n");
+	int64_t newCharacterIndex = findBeamLocationInLine(newRowIndex, oldPixelOffset);
 	placeBeamAtCharacter(newCharacterIndex, removeSelection);
+	limitScrolling(true);
 }
 
 static const uint32_t combinationKey_leftShift = 1 << 0;
@@ -412,22 +456,35 @@ bool TextBox::pointIsInside(const IVector2D& pixelPosition) {
 
 void TextBox::changedTheme(VisualTheme newTheme) {
 	this->textBox = theme_getScalableImage(newTheme, U"TextBox");
+	this->verticalScrollBar.loadTheme(newTheme, this->backColor.value);
+	this->horizontalScrollBar.loadTheme(newTheme, this->backColor.value);
 	this->hasImages = false;
 }
 
+void TextBox::loadFont() {
+	if (!font_exists(this->font)) {
+		this->font = font_getDefault();
+	}
+	if (!font_exists(this->font)) {
+		throwError("Failed to load the default font for a ListBox!\n");
+	}
+}
+
 void TextBox::completeAssets() {
 	if (this->textBox.methodIndex == -1) {
-		this->textBox = theme_getScalableImage(theme_getDefault(), U"TextBox");
-	}
-	if (this->font.get() == nullptr) {
-		this->font = font_getDefault();
+		VisualTheme newTheme = theme_getDefault();
+		this->textBox = theme_getScalableImage(newTheme, U"TextBox");
+		this->verticalScrollBar.loadTheme(newTheme, this->backColor.value);
+		this->horizontalScrollBar.loadTheme(newTheme, this->backColor.value);
 	}
+	this->loadFont();
 }
 
 void TextBox::changedLocation(const IRect &oldLocation, const IRect &newLocation) {
 	// If the component has changed dimensions then redraw the image
 	if (oldLocation.size() != newLocation.size()) {
 		this->hasImages = false;
+		this->limitScrolling(true);
 	}
 }
 
@@ -435,8 +492,52 @@ void TextBox::changedAttribute(const ReadableString &name) {
 	if (!string_caseInsensitiveMatch(name, U"Visible")) {
 		this->hasImages = false;
 		if (string_caseInsensitiveMatch(name, U"Text")) {
+			this->indexedAtLength = -1;
 			this->limitSelection();
-			this->indexedLines = false;
+			this->limitScrolling(true);
 		}
 	}
 }
+
+void TextBox::updateScrollRange() {
+	this->loadFont();
+	// How high is one element?
+	int64_t verticalStep = font_getSize(this->font);
+	// How many elements are visible at the same time?
+	int64_t visibleRangeY = (this->location.height() - this->borderY * 2) / verticalStep;
+	if (visibleRangeY < 1) visibleRangeY = 1;
+	// How many lines are there in total to see.
+	int64_t itemCount = this->lines.length() + 1; // Reserve an extra line for the horizontal scroll-bar.
+	// The range of indices that the listbox can start viewing from.
+	int64_t maxScrollY = itemCount - visibleRangeY;
+	// If visible range exceeds the collection, we should still allow starting element zero to get a valid range.
+	if (maxScrollY < 0) maxScrollY = 0;
+	// Apply the scroll range.
+	this->verticalScrollBar.updateScrollRange(ScrollRange(0, maxScrollY, visibleRangeY));
+	// Calculate range for horizontal scroll.
+	int64_t monospaceWidth = font_getMonospaceWidth(this->font);
+	int64_t rightMostPixel = this->worstCaseLineMonospaces * monospaceWidth;
+	int64_t visibleRangeX = this->location.width() - this->borderX * 2;
+	if (visibleRangeX < 1) visibleRangeX = 1;
+	int64_t maxScrollX = rightMostPixel; // Allow scrolling all the way out, so that one can write left to right without constantly panorating on a long line.
+	if (maxScrollX < 0) maxScrollX = 0;
+	this->horizontalScrollBar.updateScrollRange(ScrollRange(0, maxScrollX, visibleRangeX));
+}
+
+void TextBox::limitScrolling(bool keepBeamVisible) {
+	// Update the scroll range.
+	this->indexLines();
+	this->updateScrollRange();
+	// Limit scrolling with the updated range.
+	if (keepBeamVisible) {
+		int64_t beamRow = findBeamRow(this->lines, this->beamLocation);
+		BeamLocation beam = BeamLocation(beamRow, this->beamLocation);
+		// What will origin.x be used for?
+		int64_t pixelOffsetX = getBeamPixelOffset(this->text.value, this->font, this->lines, beam);
+		this->verticalScrollBar.limitScrolling(this->location, true, beamRow);
+		this->horizontalScrollBar.limitScrolling(this->location, true, pixelOffsetX);
+	} else {
+		this->verticalScrollBar.limitScrolling(this->location);
+		this->horizontalScrollBar.limitScrolling(this->location);
+	}
+}

+ 29 - 10
Source/DFPSR/gui/components/TextBox.h

@@ -25,9 +25,16 @@
 #define DFPSR_GUI_COMPONENT_TEXTBOX
 
 #include "../VisualComponent.h"
+#include "helpers/ScrollBarImpl.h"
 #include "../../api/fontAPI.h"
 #include "../../math/LVector.h"
 
+// TODO: Make a theme for the horizontal scroll-bar and activate it.
+// TODO: Prevent collisions between multiple scroll-bars in the corner by reserving a square in the corner when both are enabled.
+// TODO: At least make a stub implementation for cutting, copying and pasting text within the same application.
+//       Then make it optional to integrate the clipboard with the external operating system using a window handle from somewhere.
+//       Copying within the same application is still useful if one has multiple files open at the same time or just need to move things around.
+
 namespace dsr {
 
 struct LineIndex {
@@ -38,14 +45,29 @@ struct LineIndex {
 	: lineStartIndex(lineStartIndex), lineEndIndex(lineEndIndex) {}
 };
 
+struct BeamLocation {
+	int64_t rowIndex; // Index of the current row.
+	int64_t characterIndex; // Index of the character in the entire text.
+	BeamLocation(int64_t rowIndex, int64_t characterIndex)
+	: rowIndex(rowIndex), characterIndex(characterIndex) {}
+};
+
 class TextBox : public VisualComponent {
 PERSISTENT_DECLARATION(TextBox)
 public:
+	// Value allocated sub-components
+	// TODO: Let them know about each other to avoid overlap in the shared corner.
+	ScrollBarImpl verticalScrollBar = ScrollBarImpl(true);
+	ScrollBarImpl horizontalScrollBar = ScrollBarImpl(false);
+	void updateScrollRange();
+	void limitScrolling(bool keepBeamVisible);
 	// Attributes
 	PersistentColor foreColor;
 	PersistentColor backColor;
 	PersistentString text;
 	PersistentBoolean multiLine;
+	int64_t borderX = 6; // Empty pixels left and right of text.
+	int64_t borderY = 4; // Empty pixels above and below text.
 	// TODO: A setting for monospace?
 	void declareAttributes(StructureDefinition &target) const override;
 	Persistent* findAttribute(const ReadableString &name) override;
@@ -58,20 +80,16 @@ public:
 	//   From the right with beamLocation < selectionStart.
 	int64_t selectionStart = 0;
 	int64_t beamLocation = 0;
-	// TODO: Implement scrolling in pixels.
-	//       Begin by automatically following the beam and using the scroll wheel.
-	//       Panorate with right mouse button?
-	int64_t verticalScroll = 0;
-	int64_t horizontalScroll = 0;
 	// Pre-splitted version of text for fast rendering of large documents.
-	bool indexedLines = false;
 	List<LineIndex> lines;
-	void updateLines();
+	int64_t indexedAtLength = -1; // Length of the last indexed text to detect changes. Assign to -1 to force an update.
+	int64_t worstCaseLineMonospaces = 0; // An approximation of the worst case line length in monospaces, counting tabs as full length to keep it simple.
+	void indexLines();
 	void limitSelection();
-	LVector2D getTextOrigin();
+	LVector2D getTextOrigin(bool includeVerticalScroll);
 	int64_t findBeamLocationInLine(int64_t rowIndex, int64_t pixelX);
-	int64_t findBeamLocation(const LVector2D &pixelLocation);
-	void replaceSelection(const ReadableString replacingText);
+	BeamLocation findBeamLocation(const LVector2D &pixelLocation);
+	void replaceSelection(const ReadableString &replacingText);
 	void replaceSelection(DsrChar replacingCharacter);
 	void placeBeamAtCharacter(int64_t characterIndex, bool removeSelection);
 	void moveBeamVertically(int64_t rowIndexOffset, bool removeSelection);
@@ -79,6 +97,7 @@ private:
 	// Given from the style
 	MediaMethod textBox;
 	RasterFont font;
+	void loadFont();
 	void completeAssets();
 	void generateGraphics();
 	// Generated

+ 208 - 0
Source/DFPSR/gui/components/helpers/ScrollBarImpl.cpp

@@ -0,0 +1,208 @@
+// zlib open source license
+//
+// Copyright (c) 2020 to 2022 David Forsgren Piuva
+//
+// This software is provided 'as-is', without any express or implied
+// warranty. In no event will the authors be held liable for any damages
+// arising from the use of this software.
+//
+// Permission is granted to anyone to use this software for any purpose,
+// including commercial applications, and to alter it and redistribute it
+// freely, subject to the following restrictions:
+//
+//    1. The origin of this software must not be misrepresented; you must not
+//    claim that you wrote the original software. If you use this software
+//    in a product, an acknowledgment in the product documentation would be
+//    appreciated but is not required.
+//
+//    2. Altered source versions must be plainly marked as such, and must not be
+//    misrepresented as being the original software.
+//
+//    3. This notice may not be removed or altered from any source
+//    distribution.
+
+#include "ScrollBarImpl.h"
+
+using namespace dsr;
+
+void ScrollBarImpl::loadTheme(VisualTheme theme, const ColorRgbI32 &color) {
+	this->scalableImage_scrollTop = theme_getScalableImage(theme, U"ScrollUp");
+	this->scalableImage_scrollBottom = theme_getScalableImage(theme, U"ScrollDown");
+	this->scalableImage_verticalScrollKnob = theme_getScalableImage(theme, U"VerticalScrollKnob");
+	this->scalableImage_verticalScrollBackground = theme_getScalableImage(theme, U"VerticalScrollList");
+	component_generateImage(theme, this->scalableImage_scrollTop,     this->scrollBarThickness,  this->scrollButtonLength, color.red, color.green, color.blue, 0)(this->scrollButtonTopImage_normal);
+	component_generateImage(theme, this->scalableImage_scrollTop,     this->scrollBarThickness,  this->scrollButtonLength, color.red, color.green, color.blue, 1)(this->scrollButtonTopImage_pressed);	
+	component_generateImage(theme, this->scalableImage_scrollBottom,  this->scrollBarThickness,  this->scrollButtonLength, color.red, color.green, color.blue, 0)(this->scrollButtonBottomImage_normal);
+	component_generateImage(theme, this->scalableImage_scrollBottom,  this->scrollBarThickness,  this->scrollButtonLength, color.red, color.green, color.blue, 1)(this->scrollButtonBottomImage_pressed);
+}
+
+void ScrollBarImpl::updateScrollRange(const ScrollRange &range) {
+	this->scrollRange = range;
+}
+
+void ScrollBarImpl::setValue(int64_t newValue) {
+	this->value = newValue;
+}
+
+int64_t ScrollBarImpl::getValue() {
+	return this->value;
+}
+
+void ScrollBarImpl::limitScrolling(const IRect &parentLocation, bool keepPinValueInRange, int64_t pinValue) {
+	// Big enough list to need scrolling but big enough list-box to fit two buttons inside
+	this->visible = this->scrollRange.minValue < this->scrollRange.maxValue && parentLocation.width() >= scrollBarThickness * 2 && parentLocation.height() >= this->scrollButtonLength * 3;
+	if (keepPinValueInRange) {
+		// Constrain to keep pinValue in range.
+		int64_t maxScroll = pinValue;
+		int64_t minScroll = pinValue + 1 - this->scrollRange.visibleItems;
+		if (this->value > maxScroll) this->value = maxScroll;
+		if (this->value < minScroll) this->value = minScroll;
+	}
+	// Constrain value to be within the inclusive minValue..maxValue interval, in case that pinValue is out of bound.
+	if (this->value > scrollRange.maxValue) this->value = scrollRange.maxValue;
+	if (this->value < scrollRange.minValue) this->value = scrollRange.minValue;
+}
+
+IRect ScrollBarImpl::getScrollBarLocation_excludingButtons(int32_t parentWidth, int32_t parentHeight) {
+	return this->vertical ? IRect(parentWidth - this->scrollBarThickness, this->scrollButtonLength, this->scrollBarThickness, parentHeight - (this->scrollButtonLength * 2))
+	                      : IRect(this->scrollButtonLength, parentHeight - this->scrollBarThickness, parentWidth - (this->scrollButtonLength * 2), this->scrollBarThickness);
+}
+
+IRect ScrollBarImpl::getKnobLocation(int32_t parentWidth, int32_t parentHeight) {
+	// Eroded scroll-bar excluding buttons
+	// The final knob is a sub-set of this region corresponding to the visibility
+	IRect scrollBarRegion = this->getScrollBarLocation_excludingButtons(parentWidth, parentHeight);
+	// The knob should represent the selected range within the total range.
+	int64_t barLength = this->vertical ? scrollBarRegion.height() : scrollBarRegion.width();
+	int64_t barThickness = this->vertical ? scrollBarRegion.width() : scrollBarRegion.height();
+	int64_t knobLength = (barLength * this->scrollRange.visibleItems) / (this->scrollRange.maxValue + this->scrollRange.visibleItems);
+	if (knobLength < barThickness) {
+		knobLength = barThickness;
+	}
+	// Visual range for center
+	int64_t scrollStart = (this->vertical ? scrollBarRegion.top() : scrollBarRegion.left()) + knobLength / 2;
+	int64_t scrollDistance = barLength - knobLength;
+	int64_t knobStart = scrollStart + ((this->value * scrollDistance) / this->scrollRange.maxValue) - (knobLength / 2);
+	return this->vertical ? IRect(scrollBarRegion.left(), knobStart, barThickness, knobLength)
+	                      : IRect(knobStart, scrollBarRegion.top(), knobLength, barThickness);
+}
+
+void ScrollBarImpl::draw(OrderedImageRgbaU8 &target, VisualTheme &theme, const ColorRgbI32 &color) {
+	if (this->visible) {
+		int32_t parentWidth = image_getWidth(target);
+		int32_t parentHeight = image_getHeight(target);
+		IRect whole = this->vertical ? IRect(parentWidth - this->scrollBarThickness, 0, this->scrollBarThickness, parentHeight)
+		                             : IRect(0, parentHeight - this->scrollBarThickness, parentWidth, this->scrollBarThickness);
+		IRect upper = this->vertical ? IRect(whole.left(), whole.top(), whole.width(), this->scrollButtonLength)
+		                             : IRect(whole.left(), whole.top(), this->scrollButtonLength, whole.height());
+		IRect lower = this->vertical ? IRect(whole.left(), whole.bottom() - this->scrollButtonLength, whole.width(), this->scrollButtonLength)
+		                             : IRect(whole.right() - this->scrollButtonLength, whole.top(), this->scrollButtonLength, whole.height());
+		IRect knob = this->getKnobLocation(parentWidth, parentHeight);
+		// Only redraw the knob image if its dimensions changed
+		if (!image_exists(this->scrollKnobImage)
+		  || image_getWidth(this->scrollKnobImage) != knob.width()
+		  || image_getHeight(this->scrollKnobImage) != knob.height()) {
+			component_generateImage(theme, this->scalableImage_verticalScrollKnob, knob.width(), knob.height(), color.red, color.green, color.blue, 0)(this->scrollKnobImage);
+		}
+		// Only redraw the scroll list if its dimenstions changed
+		if (!image_exists(this->verticalScrollBarImage)
+		  || image_getWidth(this->verticalScrollBarImage) != whole.width()
+		  || image_getHeight(this->verticalScrollBarImage) != whole.height()) {
+			component_generateImage(theme, this->scalableImage_verticalScrollBackground, whole.width(), whole.height(), color.red, color.green, color.blue, 0)(this->verticalScrollBarImage);
+		}
+		// Draw the scroll-bar
+		draw_alphaFilter(target, this->verticalScrollBarImage, whole.left(), whole.top());
+		draw_alphaFilter(target, this->scrollKnobImage, knob.left(), knob.top());
+		draw_alphaFilter(target, this->pressScrollUp ? this->scrollButtonTopImage_pressed : this->scrollButtonTopImage_normal, upper.left(), upper.top());
+		draw_alphaFilter(target, this->pressScrollDown ? this->scrollButtonBottomImage_pressed : this->scrollButtonBottomImage_normal, lower.left(), lower.top());
+	}
+}
+
+bool ScrollBarImpl::pressScrollBar(const IRect &parentLocation, int64_t localCoordinate) {
+	int64_t oldValue = this->value;
+	IRect knobLocation = this->getKnobLocation(parentLocation.width(), parentLocation.height());
+	int64_t knobLength = this->vertical ? knobLocation.height() : knobLocation.width();
+	int64_t endDistance = this->scrollButtonLength + knobLength / 2;
+	int64_t barLength = (this->vertical ? parentLocation.height() : parentLocation.width()) - (endDistance * 2);
+	this->value = ((localCoordinate - endDistance) * this->scrollRange.maxValue + (barLength / 2)) / barLength;
+	this->limitScrolling(parentLocation);
+	// Avoid expensive redrawing if the index did not change
+	return this->value != oldValue;
+}
+
+bool ScrollBarImpl::receiveMouseEvent(const IRect &parentLocation, const MouseEvent& event) {
+	bool intercepted = false;
+	IVector2D localPosition = event.position - parentLocation.upperLeft();
+	int64_t usedCoordinate = (this->vertical ? localPosition.y : localPosition.x);
+	bool onScrollBar = this->visible && (this->vertical ? (localPosition.x >= parentLocation.width() - scrollBarThickness) : (localPosition.y >= parentLocation.height() - scrollBarThickness));
+	if (event.mouseEventType == MouseEventType::MouseDown) {
+		if (onScrollBar) {
+			intercepted = true;
+			if (this->vertical) {
+				if (localPosition.y < this->scrollButtonLength) {
+					// Upper scroll button
+					this->pressScrollUp = true;
+					this->value--;
+				} else if (localPosition.y > parentLocation.height() - this->scrollButtonLength) {
+					// Lower scroll button
+					this->pressScrollDown = true;
+					this->value++;
+				} else {
+					// Start scrolling with the mouse using the relative height on the scroll bar.
+					IRect knobLocation = this->getKnobLocation(parentLocation.width(), parentLocation.height());
+					int64_t halfKnobLength = knobLocation.height() / 2;
+					this->knobHoldOffset = localPosition.y - (knobLocation.top() + halfKnobLength);
+					if (this->knobHoldOffset < -halfKnobLength || this->knobHoldOffset > halfKnobLength) {
+						// If pressing outside of the knob, pull it directly to the pressed location before pulling from the center.
+						this->knobHoldOffset = 0;
+						this->pressScrollBar(parentLocation, usedCoordinate - this->knobHoldOffset);
+					}
+					this->holdingScrollBar = true;
+				}
+			} else {
+				if (localPosition.x < this->scrollButtonLength) {
+					// Upper scroll button
+					this->pressScrollUp = true;
+					this->value--;
+				} else if (localPosition.x > parentLocation.width() - this->scrollButtonLength) {
+					// Lower scroll button
+					this->pressScrollDown = true;
+					this->value++;
+				} else {
+					// Start scrolling with the mouse using the relative width on the scroll bar.
+					IRect knobLocation = this->getKnobLocation(parentLocation.width(), parentLocation.height());
+					int64_t halfKnobLength = knobLocation.width() / 2;
+					this->knobHoldOffset = localPosition.x - (knobLocation.width() + halfKnobLength);
+					if (this->knobHoldOffset < -halfKnobLength || this->knobHoldOffset > halfKnobLength) {
+						// If pressing outside of the knob, pull it directly to the pressed location before pulling from the center.
+						this->knobHoldOffset = 0;
+						this->pressScrollBar(parentLocation, usedCoordinate - this->knobHoldOffset);
+					}
+					this->holdingScrollBar = true;
+				}
+			}
+		}
+		this->limitScrolling(parentLocation);
+	} else if (event.mouseEventType == MouseEventType::MouseUp) {
+		this->pressScrollUp = false;
+		this->pressScrollDown = false;
+		this->holdingScrollBar = false;
+		intercepted = true;
+	} else if (event.mouseEventType == MouseEventType::Scroll) {
+		if (this->vertical) {
+			if (event.key == MouseKeyEnum::ScrollUp) {
+				this->value--;
+			} else if (event.key == MouseKeyEnum::ScrollDown) {
+				this->value++;
+			}
+			this->limitScrolling(parentLocation);
+		}
+		this->holdingScrollBar = false;
+		intercepted = true;
+	} else if (event.mouseEventType == MouseEventType::MouseMove) {
+		if (this->holdingScrollBar) {
+			intercepted = this->pressScrollBar(parentLocation, usedCoordinate - this->knobHoldOffset);
+		}
+	}
+	return intercepted;
+}

+ 100 - 0
Source/DFPSR/gui/components/helpers/ScrollBarImpl.h

@@ -0,0 +1,100 @@
+// zlib open source license
+//
+// Copyright (c) 2020 to 2022 David Forsgren Piuva
+//
+// This software is provided 'as-is', without any express or implied
+// warranty. In no event will the authors be held liable for any damages
+// arising from the use of this software.
+//
+// Permission is granted to anyone to use this software for any purpose,
+// including commercial applications, and to alter it and redistribute it
+// freely, subject to the following restrictions:
+//
+//    1. The origin of this software must not be misrepresented; you must not
+//    claim that you wrote the original software. If you use this software
+//    in a product, an acknowledgment in the product documentation would be
+//    appreciated but is not required.
+//
+//    2. Altered source versions must be plainly marked as such, and must not be
+//    misrepresented as being the original software.
+//
+//    3. This notice may not be removed or altered from any source
+//    distribution.
+
+#ifndef DFPSR_GUI_HELPER_SCROLL_BAR_IMPL
+#define DFPSR_GUI_HELPER_SCROLL_BAR_IMPL
+
+#include "../../VisualComponent.h"
+
+namespace dsr {
+
+struct ScrollRange {
+	// The minimum value the scrollbar can be assigned in the current range.
+	//   Set minValue to 0 for scrolling through items in a list.
+	int64_t minValue = 0;
+	// The maximum value the scrollbar can be assigned in the current range.
+	//   Set maxValue to length - 1 and visibleItems to 1 for selecting an item in a list directly.
+	//   Set maxValue to length - visibleItems for scrolling through items with visibleItems sepresented by the knob's length.
+	int64_t maxValue = 0;
+	// Scrolling represents a range starting at the selected value of length visibleItems.
+	//   Use 1 if you are just selecting one integer value.
+	//   Use the value that you subtracted from the total length in maxValue if you want to represent a range of multiple values.
+	//   This argument only affects the knob size and how the selected range is constrained to include a certain value.
+	int64_t visibleItems = 1;
+	ScrollRange() {}
+	ScrollRange(int64_t minValue, int64_t maxValue, int64_t visibleItems)
+	: minValue(minValue), maxValue(maxValue), visibleItems(visibleItems) {}
+};
+
+// To be value allocated inside of components that need a scroll-bar.
+// Initialized with true if the scroll-bar is vertical, and with false if the scroll-bar is horizontal.
+// Call loadTheme when the parent component loads a new theme, or you will have a crash when trying to draw the scroll-bar.
+// When the range of values or view's size changed, recalculate the range of valid values and send it to updateScrollRange.
+class ScrollBarImpl {
+private:
+	// Locked settings.
+	const bool vertical;
+	// Calculated automatically.
+	bool visible = false;
+	// Temporary.
+	bool pressScrollUp = false;
+	bool pressScrollDown = false;
+	bool holdingScrollBar = false;
+	const int scrollBarThickness = 16;
+	const int scrollButtonLength = 16;
+	int64_t knobHoldOffset = 0; // The number of pixels right or down from the center that the knob was grabbed at.
+	// Updated manually.
+	ScrollRange scrollRange; // Range of valid values.
+	int64_t value = 0; // The selected value.
+	// Scalable parametric images.
+	MediaMethod scalableImage_scrollTop, scalableImage_scrollBottom, scalableImage_verticalScrollBackground, scalableImage_verticalScrollKnob;
+	// Generated raster images.
+	OrderedImageRgbaU8 scrollButtonTopImage_normal, scrollButtonTopImage_pressed, scrollButtonBottomImage_normal, scrollButtonBottomImage_pressed, scrollKnobImage, verticalScrollBarImage;
+private:
+	IRect getScrollBarLocation_excludingButtons(int32_t parentWidth, int32_t parentHeight);
+	IRect getKnobLocation(int32_t parentWidth, int32_t parentHeight);
+	// Returns true iff value updated.
+	bool pressScrollBar(const IRect &parentLocation, int64_t localY);
+public:
+	ScrollBarImpl(bool vertical)
+	: vertical(vertical) {}
+	void loadTheme(VisualTheme theme, const ColorRgbI32 &color);
+	// Pre-condition: range.minValue <= range.maxValue
+	void updateScrollRange(const ScrollRange &range);
+	void setValue(int64_t newValue);
+	int64_t getValue();
+	// Pre-condition: scrollRange must have been updated since the last changes that may affect it
+	// Side-effects: Constrains value within minValue and maxValue.
+	//               If keepPinValueInRange is true, it will also try to constrain the selected range to include pinValue,
+	//               which is used for making sure that something selected stays visible, when scrolling is used for navigating a view.
+	void limitScrolling(const IRect &parentLocation, bool keepPinValueInRange = false, int64_t pinValue = 0);
+	// For mouse-down events, it returns true iff the event landed inside of the scroll-bar.
+	// For other event types, it returns true iff the scroll-bar needs to be redrawn.
+	bool receiveMouseEvent(const IRect &parentLocation, const MouseEvent& event);
+	// Draw the scroll-bar on top of target.
+	void draw(OrderedImageRgbaU8 &target, VisualTheme &theme, const ColorRgbI32 &color);
+};
+
+}
+
+#endif