// zlib open source license // // Copyright (c) 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 "TextBox.h" #include using namespace dsr; PERSISTENT_DEFINITION(TextBox) void TextBox::declareAttributes(StructureDefinition &target) const { VisualComponent::declareAttributes(target); target.declareAttribute(U"BackColor"); target.declareAttribute(U"ForeColor"); target.declareAttribute(U"Text"); target.declareAttribute(U"MultiLine"); target.declareAttribute(U"BackgroundClass"); } Persistent* TextBox::findAttribute(const ReadableString &name) { if (string_caseInsensitiveMatch(name, U"Color") || string_caseInsensitiveMatch(name, U"BackColor")) { return &(this->backColor); } else if (string_caseInsensitiveMatch(name, U"ForeColor")) { return &(this->foreColor); } else if (string_caseInsensitiveMatch(name, U"Text")) { return &(this->text); } else if (string_caseInsensitiveMatch(name, U"MultiLine")) { return &(this->multiLine); } else if (string_caseInsensitiveMatch(name, U"Class") || string_caseInsensitiveMatch(name, U"BackgroundClass")) { return &(this->backgroundClass); } else { return VisualComponent::findAttribute(name); } } TextBox::TextBox() {} bool TextBox::isContainer() const { return false; } // Limit exclusive indices to the text. void TextBox::limitSelection() { int64_t textLength = string_length(this->text.value); if (this->selectionStart < 0) this->selectionStart = 0; if (this->beamLocation < 0) this->beamLocation = 0; if (this->selectionStart > textLength) this->selectionStart = textLength; if (this->beamLocation > textLength) this->beamLocation = textLength; } static void tabJump(int64_t &x, int64_t tabWidth) { x += tabWidth - (x % tabWidth); } static int64_t monospacesPerTab = 4; // Pre-condition: text does not contain any linebreak. static void iterateCharactersInLine(const ReadableString& text, const RasterFont &font, std::function 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, tabWidth); } else { right += monospaceWidth; } characterAction(i, code, left, right); } } // Iterate over the whole text once for both selection and characters. // Returns the beam's X location in pixels. static 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 = 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)); font_printCharacter(target, font, code, IVector2D(left, topY), ColorRgbaI32(255, 255, 255, 255)); } else { font_printCharacter(target, font, code, IVector2D(left, topY), foreColor); } }); return beamPixelX; } 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; } } LVector2D TextBox::getTextOrigin(bool includeVerticalScroll) { int64_t rowStride = font_getSize(this->font); 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. // TODO: Allow using different colors for beam, selection, selected text, normal text... // Maybe ask a separate color palette for specific things using the specific class of textboxes. // Color palettes can be independent of the media machine, allowing them to be mixed freely with different themes. // Color palettes can be loaded together with the layout to instantly have the requested standard colors by name. // Color palettes can have a standard column order of input to easily pack multiple color themes into the same color palette image. // Just a long list of names for the different X coordinates and the user selects a Y coordinate as the color theme. // New components will have to use existing parts of the palette by keeping the names reusable. // Separate components should be able to override any color for programmability, but default values should refer to the current color palette. // 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() { 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->hasImages || this->drawnAsFocused != focused) { this->hasImages = true; this->drawnAsFocused = focused; completeAssets(); this->indexLines(); ColorRgbaI32 foreColorRgba = ColorRgbaI32(this->foreColor.value, 255); // Create a scaled 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(false); int64_t rowStride = font_getSize(this->font); int64_t targetHeight = image_getHeight(this->image); int64_t firstVisibleLine = this->verticalScrollBar.getValue(); // Find character indices for left and right sides. int64_t selectionLeft = std::min(this->selectionStart, this->beamLocation); int64_t selectionRight = std::max(this->selectionStart, this->beamLocation); bool hasSelection = selectionLeft < selectionRight; // Draw the text with selection and get the beam's pixel location. int64_t topY = origin.y; 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, 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) : foreColorRgba); } topY += rowStride; } this->verticalScrollBar.draw(this->image, this->theme, this->backColor.value); this->horizontalScrollBar.draw(this->image, this->theme, this->backColor.value); } } void TextBox::drawSelf(ImageRgbaU8& targetImage, const IRect &relativeLocation) { this->generateGraphics(); if (this->background_filter == 1) { draw_alphaFilter(targetImage, this->image, relativeLocation.left(), relativeLocation.top()); } else { draw_copy(targetImage, this->image, relativeLocation.left(), relativeLocation.top()); } } int64_t TextBox::findBeamLocationInLine(int64_t rowIndex, int64_t pixelX) { 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; int64_t beamIndex = 0; int64_t closestDistance = 1000000000000; int64_t startIndex = this->lines[rowIndex].lineStartIndex; int64_t endIndex = this->lines[rowIndex].lineEndIndex; ReadableString currentLine = string_exclusiveRange(this->text.value, startIndex, endIndex); 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; closestDistance = newDistance; } }); return startIndex + beamIndex; } 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 BeamLocation(rowIndex, this->findBeamLocationInLine(rowIndex, pixelLocation.x)); } static int64_t findBeamRow(List 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 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) { 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; 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) { 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; } if (scrollIntercepted) { this->hasImages = false; // Force redraw on scrollbar interception } else { VisualComponent::receiveMouseEvent(event); } } ReadableString TextBox::getSelectedText() { int64_t selectionLeft = std::min(this->selectionStart, this->beamLocation); int64_t selectionRight = std::max(this->selectionStart, this->beamLocation); return string_exclusiveRange(this->text.value, selectionLeft, selectionRight); } 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->hasImages = false; this->indexedAtLength = -1; this->indexLines(); this->limitScrolling(true); } void TextBox::replaceSelection(DsrChar replacingCharacter) { int64_t selectionLeft = std::min(this->selectionStart, this->beamLocation); int64_t selectionRight = std::max(this->selectionStart, this->beamLocation); String newText = string_before(this->text.value, selectionLeft); string_appendChar(newText, replacingCharacter); string_append(newText, string_from(this->text.value, selectionRight)); this->text.value = newText; // Place beam on the right side of the replacement without selecting anything this->selectionStart = selectionLeft + 1; this->beamLocation = selectionStart; this->hasImages = false; this->indexedAtLength = -1; this->indexLines(); this->limitScrolling(true); } void TextBox::placeBeamAtCharacter(int64_t characterIndex, bool removeSelection) { this->beamLocation = characterIndex; if (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 = 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 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, oldPixelOffset); placeBeamAtCharacter(newCharacterIndex, removeSelection); limitScrolling(true); } static const uint32_t combinationKey_shift = 1 << 0; static const uint32_t combinationKey_control = 1 << 1; static int64_t getLineStart(const ReadableString &text, int64_t searchStart) { for (int64_t i = searchStart - 1; i >= 0; i--) { if (text[i] == U'\n') { return i + 1; } } return 0; } static int64_t getLineEnd(const ReadableString &text, int64_t searchStart) { for (int64_t i = searchStart; i < string_length(text); i++) { if (text[i] == U'\n') { return i; } } return string_length(text); } // TODO: Use DsrKey_PageUp and DsrKey_PageDown. void TextBox::receiveKeyboardEvent(const KeyboardEvent& event) { // Insert and scroll-lock is not supported. // To prevent getting stuck from missing a key event, one can reset by pressing and releasing again. // So if you press down both control keys and release one of them, it counts both as released. if (event.keyboardEventType == KeyboardEventType::KeyDown) { if (event.dsrKey == DsrKey_Shift) { this->combinationKeys |= combinationKey_shift; // Enable shift } else if (event.dsrKey == DsrKey_Control) { this->combinationKeys |= combinationKey_control; // Enable control } } else if (event.keyboardEventType == KeyboardEventType::KeyUp) { if (event.dsrKey == DsrKey_Shift) { this->combinationKeys &= ~combinationKey_shift; // Disable shift } else if (event.dsrKey == DsrKey_Control) { this->combinationKeys &= ~combinationKey_control; // Disable control } } else if (event.keyboardEventType == KeyboardEventType::KeyType) { int64_t textLength = string_length(this->text.value); bool selected = this->selectionStart != this->beamLocation; bool printable = event.character == U'\t' || (31 < event.character && event.character < 127) || 159 < event.character; bool canGoLeft = textLength > 0 && this->beamLocation > 0; bool canGoRight = textLength > 0 && this->beamLocation < textLength; bool holdShift = this->combinationKeys & combinationKey_shift; bool holdControl = this->combinationKeys & combinationKey_control; bool removeSelection = !holdShift; if (holdControl) { if (event.dsrKey == DsrKey_LeftArrow) { // Move to the line start using Ctrl + LeftArrow instead of Home this->placeBeamAtCharacter(getLineStart(this->text.value, this->beamLocation), removeSelection); } else if (event.dsrKey == DsrKey_RightArrow) { // Move to the line end using Ctrl + RightArrow instead of End this->placeBeamAtCharacter(getLineEnd(this->text.value, this->beamLocation), removeSelection); } else if (event.dsrKey == DsrKey_X) { // Cut selection using Ctrl + X if (this->window.get()) { this->window->saveToClipboard(this->getSelectedText()); this->replaceSelection(U""); } else { sendWarning(U"No window handle found in TextBox when trying to cut text!"); } } else if (event.dsrKey == DsrKey_C) { // Copy selection using Ctrl + C if (this->window.get()) { this->window->saveToClipboard(this->getSelectedText()); } else { sendWarning(U"No window handle found in TextBox when trying to copy text!"); } } else if (event.dsrKey == DsrKey_V) { // Paste selection using Ctrl + V if (this->window.get()) { this->replaceSelection(this->window->loadFromClipboard()); } else { sendWarning(U"No window handle found in TextBox when trying to paste text!"); } } else if (event.dsrKey == DsrKey_A) { // Select all using Ctrl + A this->selectionStart = 0; this->beamLocation = string_length(this->text.value); this->hasImages = false; } else if (event.dsrKey == DsrKey_N) { // Select nothing using Ctrl + N this->selectionStart = this->beamLocation; this->hasImages = false; } } else { if (selected && (event.dsrKey == DsrKey_BackSpace || event.dsrKey == DsrKey_Delete)) { // Remove selection this->replaceSelection(U""); } else if (event.dsrKey == DsrKey_BackSpace) { if (this->selectionStart == this->beamLocation) { if (this->beamLocation > 0) { // Erase left of beam this->beamLocation--; this->replaceSelection(U""); } } else { // Erase selection this->replaceSelection(U""); } } else if (event.dsrKey == DsrKey_Delete) { if (this->selectionStart == this->beamLocation) { if (this->beamLocation < textLength) { // Erase right of beam this->beamLocation++; this->replaceSelection(U""); } } else { // Erase selection this->replaceSelection(U""); } } else if (event.dsrKey == DsrKey_Home) { // Move to the line start using Home this->placeBeamAtCharacter(getLineStart(this->text.value, this->beamLocation), removeSelection); } else if (event.dsrKey == DsrKey_End) { // Move to the line end using End this->placeBeamAtCharacter(getLineEnd(this->text.value, this->beamLocation), removeSelection); } else if (event.dsrKey == DsrKey_LeftArrow && canGoLeft) { // Move left using LeftArrow this->placeBeamAtCharacter(this->beamLocation - 1, removeSelection); } else if (event.dsrKey == DsrKey_RightArrow && canGoRight) { // Move right using RightArrow this->placeBeamAtCharacter(this->beamLocation + 1, removeSelection); } else if (event.dsrKey == DsrKey_UpArrow) { // Move up using UpArrow this->moveBeamVertically(-1, removeSelection); } else if (event.dsrKey == DsrKey_DownArrow) { // Move down using DownArrow this->moveBeamVertically(1, removeSelection); } else if (event.dsrKey == DsrKey_Return) { if (this->multiLine.value) { this->replaceSelection(U'\n'); } } else if (printable) { this->replaceSelection(event.character); } } //printText(U"KeyType char=", event.character, " key=", event.dsrKey, U"\n"); } VisualComponent::receiveKeyboardEvent(event); } bool TextBox::pointIsInside(const IVector2D& pixelPosition) { this->generateGraphics(); // Get the point relative to the component instead of its direct container IVector2D localPoint = pixelPosition - this->location.upperLeft(); // Sample opacity at the location return dsr::image_readPixel_border(this->image, localPoint.x, localPoint.y).alpha > 127; } void TextBox::loadTheme(const VisualTheme &theme) { this->finalBackgroundClass = theme_selectClass(theme, this->backgroundClass.value, U"TextBox"); this->textBox = theme_getScalableImage(theme, this->finalBackgroundClass); this->verticalScrollBar.loadTheme(theme, this->backColor.value); this->horizontalScrollBar.loadTheme(theme, this->backColor.value); this->background_filter = theme_getInteger(theme, this->finalBackgroundClass, U"Filter", 0); } void TextBox::changedTheme(VisualTheme newTheme) { this->loadTheme(newTheme); 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->loadTheme(theme_getDefault()); } 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); } } void TextBox::changedAttribute(const ReadableString &name) { if (string_caseInsensitiveMatch(name, U"BackgroundClass")) { // Update from the theme if the theme class has changed. this->changedTheme(this->getTheme()); } else if (!string_caseInsensitiveMatch(name, U"Visible")) { this->hasImages = false; if (string_caseInsensitiveMatch(name, U"Text")) { this->indexedAtLength = -1; this->limitSelection(); this->limitScrolling(true); } } VisualComponent::changedAttribute(name); } 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); } }