From 8fd8ad99614a983e855459922288231aad6f1535 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 13:15:31 -0400 Subject: [PATCH 01/41] A text view keeps carets and selections besides its main one ALTextCarets holds a view's other selections, each anchor to caret, in the order they begin, and slides them with the text's edits: a caret is pushed past what is typed at it, as the main one is, and a selection keeps what it held, what goes in at either edge staying outside it -- which Edit::placed now offers, a place left before text put right at it. Two that overlap become one, and so do a caret and whatever it touches; two selections side by side stay two. One the main selection meets becomes part of it, the main one keeping its place and its way, so that everything that knows of one selection goes on as it did. The view washes each selection and draws each caret as it does the main one, in its style and blinking with it, every caret in sight in one draw. A step to undo keeps the others as they stood before it and after, undo and redo put them back, and a history written out keeps them too. Nothing makes them yet but the view's own calls. Co-Authored-By: Claude Opus 5.5 --- indra/llui/CMakeLists.txt | 3 + indra/llui/altextcarets.cpp | 164 ++++++++++++++++++ indra/llui/altextcarets.h | 105 ++++++++++++ indra/llui/altextdocument.cpp | 8 +- indra/llui/altextdocument.h | 6 +- indra/llui/altextundo.cpp | 71 +++++++- indra/llui/altextundo.h | 30 ++-- indra/llui/altextview.cpp | 179 ++++++++++++++----- indra/llui/altextview.h | 32 +++- indra/llui/tests/altextcarets_test.cpp | 218 ++++++++++++++++++++++++ indra/llui/tests/altextundo_test.cpp | 65 +++++++ indra/llui/tests/altextview_gl_test.cpp | 106 ++++++++++++ indra/llui/tests/altextview_test.cpp | 80 +++++++++ 13 files changed, 1003 insertions(+), 64 deletions(-) create mode 100644 indra/llui/altextcarets.cpp create mode 100644 indra/llui/altextcarets.h create mode 100644 indra/llui/tests/altextcarets_test.cpp diff --git a/indra/llui/CMakeLists.txt b/indra/llui/CMakeLists.txt index 2ffecb1c5b..d7a84bfffe 100644 --- a/indra/llui/CMakeLists.txt +++ b/indra/llui/CMakeLists.txt @@ -164,6 +164,7 @@ target_sources( alsyntaxgrammar.cpp alsyntaxhighlighter.cpp altabstrip.cpp + altextcarets.cpp altextdiff.cpp altextdocument.cpp altextediting.cpp @@ -387,6 +388,7 @@ target_sources( alsyntaxgrammar.h alsyntaxhighlighter.h altabstrip.h + altextcarets.h altextdiff.h altextdocument.h altextediting.h @@ -500,6 +502,7 @@ if(AL_BUILD_TESTS) al_add_test(alfenwicktree PROJECT llui LIBRARIES ${test_libs}) al_add_test(allinetable PROJECT llui LIBRARIES ${test_libs}) al_add_test(altextdocument UNIT PROJECT llui LIBRARIES ${test_libs}) + al_add_test(altextcarets UNIT PROJECT llui SOURCES altextdocument.cpp LIBRARIES ${test_libs}) al_add_test(altextundo UNIT PROJECT llui SOURCES altextdocument.cpp LIBRARIES ${test_libs}) al_add_test(alnotecarditems UNIT PROJECT llui LIBRARIES ${test_libs}) al_add_test( diff --git a/indra/llui/altextcarets.cpp b/indra/llui/altextcarets.cpp new file mode 100644 index 0000000000..5d3645f40b --- /dev/null +++ b/indra/llui/altextcarets.cpp @@ -0,0 +1,164 @@ +/** + * @file altextcarets.cpp + * @brief The selections a text view has besides its main one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "altextcarets.h" + +void ALTextCarets::assign(std::vector selections) +{ + mSelections.assign(std::move(selections)); + sweep(nullptr); +} + +void ALTextCarets::add(const ALTextRange& selection) +{ + mSelections.insert(selection); + sweep(nullptr); +} + +bool ALTextCarets::merge(ALTextRange& main) +{ + return sweep(&main); +} + +void ALTextCarets::apply(const ALTextDocument::Edit& edit) +{ + if (mSelections.empty()) + { + return; + } + bool moved = false; + mSelections.apply( + edit, + [&moved](ALTextRange& selection, const ALTextDocument::Edit& e) { + const ALTextRange after = slid(selection, e); + moved = moved || after != selection; + selection = after; + return true; + }, + [](ALTextRange&) {}); + // Only what moved can have come to meet another. + if (moved) + { + sweep(nullptr); + } +} + +// static +ALTextRange ALTextCarets::slid(const ALTextRange& selection, const ALTextDocument::Edit& edit) +{ + if (selection.empty()) + { + const ALTextPos at = edit.placed(selection.end); + return ALTextRange(at, at); + } + const ALTextRange ordered = selection.normalised(); + const ALTextPos begin = edit.placed(ordered.begin); + const ALTextPos end = std::max(begin, edit.placed(ordered.end, false)); + return selection.end < selection.begin ? ALTextRange(end, begin) : ALTextRange(begin, end); +} + +// static +bool ALTextCarets::meet(const ALTextRange& first, const ALTextRange& second) +{ + const ALTextRange a = first.normalised(); + const ALTextRange b = second.normalised(); + return (a.empty() || b.empty()) ? b.begin <= a.end : b.begin < a.end; +} + +// static +ALTextRange ALTextCarets::joined(const ALTextRange& winner, const ALTextRange& loser) +{ + const ALTextRange w = winner.normalised(); + const ALTextRange l = loser.normalised(); + const ALTextPos begin = std::min(w.begin, l.begin); + const ALTextPos end = std::max(w.end, l.end); + const ALTextRange& way = winner.empty() ? loser : winner; + return way.end < way.begin ? ALTextRange(end, begin) : ALTextRange(begin, end); +} + +bool ALTextCarets::sweep(ALTextRange* main) +{ + if (mSelections.empty()) + { + return false; + } + // In the order they begin, the main selection before any that begins + // where it does; each joined to the one kept before it where they meet, + // the main one winning, and the one kept first otherwise. + std::vector items = mSelections.take(); + const ALTextRange was = main ? *main : ALTextRange(); + std::vector kept; + kept.reserve(items.size()); + bool main_kept = false; + // Which of those kept is the main selection, once it is. + size_t main_at = 0; + const auto take = [&](const ALTextRange& next, bool is_main) { + if (!kept.empty() && meet(kept.back(), next)) + { + kept.back() = is_main ? joined(next, kept.back()) : joined(kept.back(), next); + } + else + { + kept.push_back(next); + } + if (is_main) + { + main_kept = true; + main_at = kept.size() - 1; + } + }; + const ALTextPos main_begin = was.normalised().begin; + bool main_due = main != nullptr; + for (const ALTextRange& item : items) + { + if (main_due && main_begin <= item.normalised().begin) + { + take(was, true); + main_due = false; + } + take(item, false); + } + if (main_due) + { + take(was, true); + } + // Back as they were: the main one to the view, the rest here, still in + // the order they begin. + for (size_t i = 0; i < kept.size(); ++i) + { + if (main_kept && i == main_at) + { + continue; + } + mSelections.push_back(kept[i]); + } + if (!main) + { + return false; + } + *main = kept[main_at]; + return *main != was; +} diff --git a/indra/llui/altextcarets.h b/indra/llui/altextcarets.h new file mode 100644 index 0000000000..5a4ec29011 --- /dev/null +++ b/indra/llui/altextcarets.h @@ -0,0 +1,105 @@ +/** + * @file altextcarets.h + * @brief The selections a text view has besides its main one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#ifndef AL_ALTEXTCARETS_H +#define AL_ALTEXTCARETS_H + +#include "alanchoredranges.h" + +#include +#include + +// The selections a view has besides its main one, which stays the view's +// own so that everything that knows of one selection goes on working. Each +// is a range from its anchor to its caret, as the view's selection is -- +// the caret before the anchor where it was made backwards -- and an empty +// one is a caret alone. They are kept in the order they begin and in step +// with the text's edits, as ALAnchoredRanges keeps what is laid over it: +// a caret is pushed along by text put in right at it, as typing pushes the +// main one, and a selection keeps what it held, what is put in at either +// edge staying outside it. +// +// None is over another. Two selections that overlap become one, and so do +// a caret and a selection or a caret it touches; two selections that only +// touch -- two matches side by side -- stay two. One that meets the main +// selection becomes part of it, the main one keeping its place as main. +class ALTextCarets +{ +public: + // Where one is in the text, in order: its range, normalised. + struct Normalised + { + ALTextRange operator()(const ALTextRange& selection) const { return selection.normalised(); } + }; + typedef ALAnchoredRanges ranges_t; + typedef ranges_t::const_iterator const_iterator; + + const std::vector& selections() const { return mSelections.items(); } + size_t size() const { return mSelections.size(); } + bool empty() const { return mSelections.empty(); } + const ALTextRange& operator[](size_t i) const { return mSelections[i]; } + const_iterator begin() const { return mSelections.begin(); } + const_iterator end() const { return mSelections.end(); } + // Those that may lie on a line, in order; each still asked whether it + // does, one that began above it having perhaps ended. + std::pair onLine(S32 line) const { return mSelections.onLine(line); } + + // All of them at once, or one more, merged where they meet each other. + // The main selection is merged with them by merge(), which whoever + // holds it asks once it has put it. + void assign(std::vector selections); + void add(const ALTextRange& selection); + void clear() { mSelections.clear(); } + + // Those that meet merged, the main selection with them, which grows by + // any it meets: what the main one put somewhere asks. Whether it changed. + bool merge(ALTextRange& main); + + // An edit made: each slid along with the text, and those it brought + // together merged -- among themselves, since the main selection is + // placed by whoever made the edit. + void apply(const ALTextDocument::Edit& edit); + + // A selection moved along by an edit as these are: a caret pushed past + // what was put in at it, a selection's ends kept outside what was put + // in at them, and either way back to where the edit began where the + // edit took what it stood on. + static ALTextRange slid(const ALTextRange& selection, const ALTextDocument::Edit& edit); + // Whether two selections, the first beginning at or before the second, + // are one: overlapping, or touching where either is a caret. + static bool meet(const ALTextRange& first, const ALTextRange& second); + // Two that meet as one, over both, running the way `winner` runs -- its + // caret at the end or at the start -- or the way `loser` does where the + // winner is a caret. + static ALTextRange joined(const ALTextRange& winner, const ALTextRange& loser); + +private: + // Each that meets the one before it joined to it, the main selection + // among them where one is given. + bool sweep(ALTextRange* main); + + ranges_t mSelections; +}; + +#endif // AL_ALTEXTCARETS_H diff --git a/indra/llui/altextdocument.cpp b/indra/llui/altextdocument.cpp index 880a85b535..ba0479cd60 100644 --- a/indra/llui/altextdocument.cpp +++ b/indra/llui/altextdocument.cpp @@ -189,12 +189,14 @@ const std::vector& ALTextDocument::Edit::lineSpa return out; } -ALTextPos ALTextDocument::Edit::placed(const ALTextPos& pos) const +ALTextPos ALTextDocument::Edit::placed(const ALTextPos& pos, bool pushed) const { if (parts.empty()) { const ALTextRange removed = range.normalised(); - if (pos < removed.begin) + // Text put in right at it, and nothing taken, leaves it where it + // was unless it is pushed. + if (pos < removed.begin || (!pushed && pos == removed.begin)) { return pos; } @@ -205,7 +207,7 @@ ALTextPos ALTextDocument::Edit::placed(const ALTextPos& pos) const { return in->after.begin; } - return mapped(pos, true); + return mapped(pos, pushed); } S32 ALTextDocument::Edit::lineAfter(S32 line) const diff --git a/indra/llui/altextdocument.h b/indra/llui/altextdocument.h index be74ed32b5..96abcbecdd 100644 --- a/indra/llui/altextdocument.h +++ b/indra/llui/altextdocument.h @@ -176,8 +176,10 @@ class ALTextDocument // A place kept beside the text -- a mark -- as a keeper of marks has // it: before what was replaced it stays, inside it lands where that // began, after it moves along. For a batch, by the stretch it is in - // or the ones before it. - ALTextPos placed(const ALTextPos& pos) const; + // or the ones before it. Text put in right at it pushes it along, + // as typing pushes a caret, unless not `pushed`: the end of a + // selection, which what is put in right after it is not part of. + ALTextPos placed(const ALTextPos& pos, bool pushed = true) const; // Where a line is after, or -1 for one the edit replaced: one of a // run of lines, a line typed in included, is not the line it was. S32 lineAfter(S32 line) const; diff --git a/indra/llui/altextundo.cpp b/indra/llui/altextundo.cpp index 974d7d5ce1..722f8159ea 100644 --- a/indra/llui/altextundo.cpp +++ b/indra/llui/altextundo.cpp @@ -96,6 +96,29 @@ namespace // counts, so that a history of many small edits is held to it too. constexpr size_t STEP_WRITTEN = 48; constexpr size_t EDIT_WRITTEN = 16; + // And each other selection a step keeps, before or after. + constexpr size_t RANGE_WRITTEN = 20; + + LLSD rangesAsLLSD(const std::vector& ranges) + { + LLSD out = LLSD::emptyArray(); + for (const ALTextRange& range : ranges) + { + out.append(LLSD::emptyArray().with(0, range.begin.line).with(1, range.begin.column).with(2, range.end.line).with(3, range.end.column)); + } + return out; + } + + std::vector rangesFrom(const LLSD& sd) + { + std::vector out; + for (LLSD::array_const_iterator it = sd.beginArray(); it != sd.endArray(); ++it) + { + const LLSD& one = *it; + out.emplace_back(ALTextPos(one[0].asInteger(), one[1].asInteger()), ALTextPos(one[2].asInteger(), one[3].asInteger())); + } + return out; + } // An edit as it is written: where it begins and ends, what it took and // what it put -- an array rather than a map, since a history is mostly @@ -249,6 +272,8 @@ void ALTextUndo::join(Step& last, Step&& next) } last.caretAfter = next.caretAfter; last.anchorAfter = next.anchorAfter; + last.bytes = last.bytes - RANGE_WRITTEN * last.othersAfter.size() + RANGE_WRITTEN * next.othersAfter.size(); + last.othersAfter = std::move(next.othersAfter); last.written.clear(); } @@ -270,6 +295,21 @@ void ALTextUndo::settle(const ALTextRange& selection) newest.written.clear(); } +void ALTextUndo::settle(const ALTextRange& selection, std::vector others) +{ + if (!mSettling || mSteps.undone().empty()) + { + return; + } + settle(selection); + Step& newest = mSteps.newest(); + // What the others weigh, with the step and with the steps back. + const size_t was = newest.bytes; + newest.bytes = newest.bytes - RANGE_WRITTEN * newest.othersAfter.size() + RANGE_WRITTEN * others.size(); + newest.othersAfter = std::move(others); + mUndoneBytes = mUndoneBytes - was + newest.bytes; +} + void ALTextUndo::label(std::string_view text) { ++mRevision; @@ -281,7 +321,8 @@ void ALTextUndo::label(std::string_view text) mSteps.label(text); } -void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& before_in, const ALTextPos& after, F64 now) +void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& before_in, const ALTextPos& after, F64 now, + std::vector others_before) { if (edit.nothing()) { @@ -304,8 +345,9 @@ void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& bef step.anchorBefore = before_in.begin; step.caretAfter = after; step.anchorAfter = after; + step.othersBefore = std::move(others_before); step.serial = ++mNextSerial; - step.bytes = STEP_WRITTEN + EDIT_WRITTEN + edit.removed.size() + edit.inserted.size(); + step.bytes = STEP_WRITTEN + EDIT_WRITTEN + edit.removed.size() + edit.inserted.size() + RANGE_WRITTEN * step.othersBefore.size(); step.typed = mTypingDepth > 0 && !mSteps.inGroup(); // The key a run is joined by: the kind of change, where it carries on @@ -434,7 +476,7 @@ void ALTextUndo::closeGroups() forgetOverBudget(); } -std::optional ALTextUndo::undo() +std::optional ALTextUndo::undo(std::vector* others) { mResumeStep = 0; std::optional step = mSteps.takeUndo(); @@ -451,11 +493,15 @@ std::optional ALTextUndo::undo() mDocument.replace(back.range, back.inserted, back.parts); } const ALTextRange selection(step->anchorBefore, step->caretBefore); + if (others) + { + *others = step->othersBefore; + } mSteps.pushRedo(std::move(*step)); return selection; } -std::optional ALTextUndo::redo() +std::optional ALTextUndo::redo(std::vector* others) { mResumeStep = 0; std::optional step = mSteps.takeRedo(); @@ -471,6 +517,10 @@ std::optional ALTextUndo::redo() mDocument.replace(edit.range, edit.inserted, edit.parts); } const ALTextRange selection(step->anchorAfter, step->caretAfter); + if (others) + { + *others = step->othersAfter; + } mSteps.pushUndo(std::move(*step)); return selection; } @@ -513,6 +563,15 @@ namespace { out["anchor_after"] = posAsLLSD(step.anchorAfter); } + // The other selections only where there were any. + if (!step.othersBefore.empty()) + { + out["others_before"] = rangesAsLLSD(step.othersBefore); + } + if (!step.othersAfter.empty()) + { + out["others_after"] = rangesAsLLSD(step.othersAfter); + } LLSD edits = LLSD::emptyArray(); std::optional pending; for (const ALTextDocument::Edit& edit : step.edits) @@ -660,7 +719,9 @@ std::optional ALTextUndo::historyFrom(const LLSD& sd, std:: step.caretAfter = posFrom(one["after"]); step.anchorBefore = one.has("anchor_before") ? posFrom(one["anchor_before"]) : step.caretBefore; step.anchorAfter = one.has("anchor_after") ? posFrom(one["anchor_after"]) : step.caretAfter; - step.bytes = STEP_WRITTEN; + step.othersBefore = rangesFrom(one["others_before"]); + step.othersAfter = rangesFrom(one["others_after"]); + step.bytes = STEP_WRITTEN + RANGE_WRITTEN * (step.othersBefore.size() + step.othersAfter.size()); for (LLSD::array_const_iterator it = one["edits"].beginArray(); it != one["edits"].endArray(); ++it) { ALTextDocument::Edit edit; diff --git a/indra/llui/altextundo.h b/indra/llui/altextundo.h index 3555144aae..3dbd1c6d08 100644 --- a/indra/llui/altextundo.h +++ b/indra/llui/altextundo.h @@ -36,7 +36,8 @@ // A document's edits as steps a person can take back: each one the edits // it was made of, in order, with the selection -- its anchor and its caret -// -- as it stood before and after. +// -- as it stood before and after, and the other selections a view with +// several had besides it. // A run of typing is one step -- characters typed one after the other, each // where the last ended, within a moment of each other -- and so is a run of // backspaces or deletes; anything recorded while a group is open is one @@ -61,6 +62,10 @@ class ALTextUndo // where nothing was selected. ALTextPos anchorBefore; ALTextPos anchorAfter; + // The selections besides that one, each anchor to caret + // (ALTextCarets): none where there was only the one. + std::vector othersBefore; + std::vector othersAfter; std::string mLabel; // Which step this is, for a save point to find it by; a run // joined to it keeps the number it began with. @@ -100,16 +105,20 @@ class ALTextUndo explicit ALTextUndo(ALTextDocument& document); // A change made through the document, with where the caret was and - // is -- or the selection it was made over, anchor to caret. `now` is in - // seconds from any clock, and the window is how close two changes have - // to be to join a run. + // is -- or the selection it was made over, anchor to caret, and the + // others beside it. `now` is in seconds from any clock, and the window + // is how close two changes have to be to join a run. void record(const ALTextDocument::Edit& edit, const ALTextPos& before, const ALTextPos& after, F64 now); - void record(const ALTextDocument::Edit& edit, const ALTextRange& before, const ALTextPos& after, F64 now); + void record(const ALTextDocument::Edit& edit, const ALTextRange& before, const ALTextPos& after, F64 now, + std::vector others_before = {}); // Where the selection ends up once the change that was just recorded is // done -- a replace-all putting the caret back where it was, a line - // moved with its selection -- for a redo to put it there. Nothing once - // a step has been taken back or forward since. + // moved with its selection -- for a redo to put it there; and the others + // with it, where they are given, else as they were last said. Until + // they are said, a redo leaves only the caret. Nothing once a step has + // been taken back or forward since. void settle(const ALTextRange& selection); + void settle(const ALTextRange& selection, std::vector others); void setRunWindow(F64 seconds) { mWindow = seconds; } // What one key typed does -- a character, a pair and its closer, a @@ -141,9 +150,10 @@ class ALTextUndo bool inGroup() const { return mSteps.inGroup(); } // The step back and the step forward, applied. The selection it puts - // back, anchor to caret, or nothing where there was nothing to do. - std::optional undo(); - std::optional redo(); + // back, anchor to caret, or nothing where there was nothing to do; and + // the others it puts back beside it, where they are asked for. + std::optional undo(std::vector* others = nullptr); + std::optional redo(std::vector* others = nullptr); bool canUndo() const { return mSteps.canUndo(); } bool canRedo() const { return mSteps.canRedo(); } // Nothing to step back or forward to, and the text as it stands taken diff --git a/indra/llui/altextview.cpp b/indra/llui/altextview.cpp index 1e48080b44..38a217a1c4 100644 --- a/indra/llui/altextview.cpp +++ b/indra/llui/altextview.cpp @@ -421,6 +421,9 @@ void ALTextView::setText(std::string_view text) mPreeditSegmentEnds.clear(); mPreeditStandouts.clear(); mPreeditOverwritten.clear(); + // One caret in a new text, before the edit that puts it in slides the + // others to its start for nothing. + mCarets.clear(); mDocument.setText(text); mUndo.clear(); // A new text has no changes to go back to. @@ -899,6 +902,16 @@ void ALTextView::placeSelection(const ALTextPos& anchor, const ALTextPos& caret) const ALTextRange was = selection(); mAnchor = mDocument.clamp(anchor); mCaret = snapped(mDocument.clamp(caret), was.end); + // The others it now meets become part of it. + if (!mCarets.empty()) + { + ALTextRange main(mAnchor, mCaret); + if (mCarets.merge(main)) + { + mAnchor = main.begin; + mCaret = main.end; + } + } if (mLayout.hidden(mCaret.line)) { if (mFeatures) @@ -956,6 +969,41 @@ void ALTextView::setSelection(const ALTextRange& range) scrollToCaret(); } +// --- several carets ------------------------------------------------------------ + +std::vector ALTextView::clamped(std::vector selections) const +{ + for (ALTextRange& one : selections) + { + one = ALTextRange(mDocument.clamp(one.begin), mDocument.clamp(one.end)); + } + return selections; +} + +void ALTextView::addSelection(const ALTextRange& range) +{ + mCarets.add(ALTextRange(mDocument.clamp(range.begin), mDocument.clamp(range.end))); + // Put where it is again, to take in the new one where they meet. + placeSelection(mAnchor, mCaret); +} + +void ALTextView::setSelections(const ALTextRange& main, std::vector others) +{ + mCarets.assign(clamped(std::move(others))); + setSelection(main); +} + +bool ALTextView::singleSelection() +{ + if (mCarets.empty()) + { + return false; + } + mCarets.clear(); + mBlink.reset(); + return true; +} + std::string ALTextView::wordBeforeCaret() const { const std::string& line = mDocument.line(mCaret.line); @@ -1782,8 +1830,10 @@ void ALTextView::onDocumentEdit(const ALTextDocument::Edit& edit) // drawn; the ones below slide. mSpelling.edited(edit, mDocument.lineCount()); mSpellTimer.reset(); - // What the find bar found slides with the text. + // What the find bar found slides with the text, and so do the carets + // besides the main one, which whoever made the edit puts. mFind.edited(edit); + mCarets.apply(edit); // The layers: what is after the edit slides with the text, what it // cut through goes. @@ -1910,14 +1960,15 @@ ALTextDocument::Edit ALTextView::edit(const ALTextRange& range_in, std::string_v range = withoutComposition(range_in); resetPreedit(); } - const ALTextRange before = selection(); - ALTextDocument::Edit done = mDocument.replace(range, fitting(range, text)); + const ALTextRange before = selection(); + std::vector others = mCarets.selections(); + ALTextDocument::Edit done = mDocument.replace(range, fitting(range, text)); if (done.nothing()) { return done; } const ALTextPos after = mDocument.clamp(done.endAfter()); - mUndo.record(done, before, after, LLTimer::getElapsedSeconds()); + mUndo.record(done, before, after, LLTimer::getElapsedSeconds(), std::move(others)); placeCaret(after, false); return done; } @@ -1936,14 +1987,15 @@ ALTextDocument::Edit ALTextView::editMany(std::vector others = mCarets.selections(); + ALTextDocument::Edit done = mDocument.replaceMany(std::move(edits)); if (done.nothing()) { return done; } const ALTextPos after = mDocument.clamp(caret); - mUndo.record(done, before, after, LLTimer::getElapsedSeconds()); + mUndo.record(done, before, after, LLTimer::getElapsedSeconds(), std::move(others)); placeCaret(after, false); return done; } @@ -2002,9 +2054,9 @@ void ALTextView::full() void ALTextView::afterEdit() { editsDone(); - // Where the change left the selection, for a redo to put it back + // Where the change left the selections, for a redo to put them back // there; nothing once a step has been taken back or forward. - mUndo.settle(selection()); + mUndo.settle(selection(), mCarets.selections()); mDesiredX = -1.f; mChangedSinceFocus = true; mBlink.reset(); @@ -2678,8 +2730,10 @@ void ALTextView::undo() { resetPreedit(); } - if (std::optional selected = mUndo.undo()) + std::vector others; + if (std::optional selected = mUndo.undo(&others)) { + mCarets.assign(clamped(std::move(others))); placeSelection(selected->begin, selected->end); afterEdit(); } @@ -2695,8 +2749,10 @@ void ALTextView::redo() { resetPreedit(); } - if (std::optional selected = mUndo.redo()) + std::vector others; + if (std::optional selected = mUndo.redo(&others)) { + mCarets.assign(clamped(std::move(others))); placeSelection(selected->begin, selected->end); afterEdit(); } @@ -3869,9 +3925,6 @@ void ALTextView::drawRows(const LLRect& text) const F32 blink = mBlink.getElapsedTimeF32(); const bool caret_on = show_caret && (!mCaretBlink || blink < BLINK_DELAY || (static_cast(blink * 2.f) & 1)); const ALTextRange sel = selection().normalised(); - const bool has_sel = !sel.empty(); - S32 caret_row = 0; - const F32 caret_x = mLayout.xOf(mCaret.line, mCaret.column, &caret_row); const F32 space = mLayout.xOf(0, 0) + 6.f; // what a selected line end is drawn as const F32 left = static_cast(text.mLeft) - mScrollX; @@ -3913,8 +3966,9 @@ void ALTextView::drawRows(const LLRect& text) } } - // Behind the text: the selection and what the find bar found. - const std::vector& matches = mFind.matches(); + // Behind the text: the selections and what the find bar found. + const std::vector& matches = mFind.matches(); + const LLColor4 sel_color = selectionDrawColor() % alpha; gGL.getTextureSlot(0)->unbind(); gGL.begin(LLRender::TRIANGLES); for (const RowSeen& seen : mRowsSeen) @@ -3923,10 +3977,16 @@ void ALTextView::drawRows(const LLRect& text) const ALTextLayout::Line& laid = mLayout.line(line); const ALTextLayout::Row& row = laid.rows[static_cast(seen.row)]; const S32 length = mDocument.lineLength(line); - if (has_sel && sel.begin.line <= line && line <= sel.end.line) - { - const S32 sel_begin = sel.begin.line < line ? 0 : sel.begin.column; - const S32 sel_end = sel.end.line > line ? length + 1 : sel.end.column; + // A selection's band on the row, in order, from its start or the + // row's to its end or the row's; past the line's end where it goes + // on to the next. + const auto band = [&](const ALTextRange& range) { + if (range.empty() || line < range.begin.line || range.end.line < line) + { + return; + } + const S32 sel_begin = range.begin.line < line ? 0 : range.begin.column; + const S32 sel_end = range.end.line > line ? length + 1 : range.end.column; const S32 lo = llmax(sel_begin, row.begin); const bool last_row = (static_cast(seen.row) + 1 == laid.rows.size()); const S32 hi = llmin(sel_end, last_row ? length + 1 : row.end); @@ -3935,8 +3995,13 @@ void ALTextView::drawRows(const LLRect& text) const F32 x0 = mLayout.xOf(line, lo); const F32 x1 = hi > length ? row.width + space : (hi >= row.end && !last_row ? row.width : mLayout.xOf(line, hi)); gl_rect_2d_in_batch(static_cast(left + x0), seen.rowScreenTop, static_cast(left + x1), seen.rowScreenTop - row.height, - selectionDrawColor() % alpha); + sel_color); } + }; + band(sel); + for (auto [it, end] = mCarets.onLine(line); it != end; ++it) + { + band(it->normalised()); } if (!matches.empty()) { @@ -4000,39 +4065,69 @@ void ALTextView::drawRows(const LLRect& text) drawLayers(line, laid, static_cast(r), text, row_screen_top, left, alpha); drawRowExtras(line, static_cast(r), text, screen_top, left, alpha); - // The caret. - if (caret_on && line == mCaret.line && static_cast(r) == caret_row) + // The carets on the row, every one as the main one is. + if (caret_on) { - const S32 x = static_cast(left + caret_x); - const bool modal = mModal && !mModal->inserting(); - if (modal || mCaretStyle != CaretStyle::Line) - { - // A block over the cluster the caret is on, as a modal - // editor's is; a space's width past the line's end. - const ALTextPos next = mDocument.nextCluster(mCaret); - const F32 cell = llmax(4.f, mLayout.columnWidth()); - F32 right = next.line == mCaret.line && next != mCaret ? mLayout.xOf(mCaret.line, next.column) : caret_x + cell; - if (right <= caret_x) + const auto caret = [&](const ALTextPos& at) { + if (at.line != line) + { + return; + } + S32 at_row = 0; + const F32 at_x = mLayout.xOf(at.line, at.column, &at_row); + if (at_row != static_cast(r)) { - right = caret_x + cell; + return; } - if (!modal && mCaretStyle == CaretStyle::Underline) + const S32 x = static_cast(left + at_x); + const bool modal = mModal && !mModal->inserting(); + if (modal || mCaretStyle != CaretStyle::Line) { - // A bar under the cluster, as thick as the line caret is wide. - gl_rect_2d(x, row_screen_top - row.height + CARET_WIDTH, static_cast(left + right), row_screen_top - row.height, - mCursorColor.get() % alpha); + // A block over the cluster the caret is on, as a modal + // editor's is; a space's width past the line's end. + const ALTextPos next = mDocument.nextCluster(at); + const F32 cell = llmax(4.f, mLayout.columnWidth()); + F32 right = next.line == at.line && next != at ? mLayout.xOf(at.line, next.column) : at_x + cell; + if (right <= at_x) + { + right = at_x + cell; + } + if (!modal && mCaretStyle == CaretStyle::Underline) + { + // A bar under the cluster, as thick as the line caret is wide. + mCaretBoxes.push_back({ LLRect(x, row_screen_top - row.height + CARET_WIDTH, static_cast(left + right), row_screen_top - row.height), + mCursorColor.get() % alpha }); + } + else + { + mCaretBoxes.push_back({ LLRect(x, row_screen_top, static_cast(left + right), row_screen_top - row.height), + mCursorColor.get() % (0.55f * alpha) }); + } } else { - gl_rect_2d(x, row_screen_top, static_cast(left + right), row_screen_top - row.height, mCursorColor.get() % (0.55f * alpha)); + mCaretBoxes.push_back({ LLRect(x, row_screen_top, x + CARET_WIDTH, row_screen_top - row.height), mCursorColor.get() % alpha }); } - } - else + }; + caret(mCaret); + for (auto [it, end] = mCarets.onLine(line); it != end; ++it) { - gl_rect_2d(x, row_screen_top, x + CARET_WIDTH, row_screen_top - row.height, mCursorColor.get() % alpha); + caret(it->end); } } } + // The carets together, over every row's layers. + if (!mCaretBoxes.empty()) + { + gGL.getTextureSlot(0)->unbind(); + gGL.begin(LLRender::TRIANGLES); + for (const CaretBox& box : mCaretBoxes) + { + gl_rect_2d_in_batch(box.rect.mLeft, box.rect.mTop, box.rect.mRight, box.rect.mBottom, box.color); + } + gGL.end(); + mCaretBoxes.clear(); + } mQueueSquiggles = false; drawSquiggles(mSquiggles.data(), mSquiggles.size()); mSquiggles.clear(); diff --git a/indra/llui/altextview.h b/indra/llui/altextview.h index 1b8ebd54ae..3e0b9c7e6b 100644 --- a/indra/llui/altextview.h +++ b/indra/llui/altextview.h @@ -27,6 +27,7 @@ #include "alanchoredranges.h" #include "alkeymap.h" #include "alsyntaxhighlighter.h" +#include "altextcarets.h" #include "altextdocument.h" #include "altextediting.h" #include "altextfind.h" @@ -58,8 +59,9 @@ class ALVimHost; class LLContextMenu; // A view of a document: the lines laid out and drawn, only the ones in -// sight, a caret and a selection in them, a keymap that turns keys into -// commands, and a grammar that colours what it shows. Text is drawn from +// sight, a caret and a selection in them -- and others beside those, +// where something made them -- a keymap that turns keys into commands, +// and a grammar that colours what it shows. Text is drawn from // glyph runs the layout shaped once, so a frame costs the rows on screen // and nothing that is not. The document, the undo journal, the highlighter // and the layout are its own, and reachable, for whatever is built over it @@ -345,6 +347,21 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec // underscores back from the caret. Empty at anything else. std::string wordBeforeCaret() const; + // --- several carets --------------------------------------------------------- + + // The selections besides the main one, each anchor to caret, in the + // order they begin (ALTextCarets). Drawn as the main one is, slid along + // by every edit, and kept by each step to undo; merged with each other + // and with the main one where they meet. + const std::vector& otherSelections() const { return mCarets.selections(); } + bool hasOtherSelections() const { return !mCarets.empty(); } + // One more beside the main one, which stays main. + void addSelection(const ALTextRange& range); + // The main selection and the others at once. + void setSelections(const ALTextRange& main, std::vector others); + // The main selection alone again; whether there were others. + bool singleSelection(); + // --- editing, through the undo journal ----------------------------------- // In place of the selection, or at the caret. @@ -1071,6 +1088,10 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec ALTextPos mCaret; ALTextPos mAnchor; + // The selections besides that one. + ALTextCarets mCarets; + // Each into the text, as the main one is put. + std::vector clamped(std::vector selections) const; // The x the caret wants when it moves between rows, or negative. F32 mDesiredX = -1.f; S32 mScrollY = 0; @@ -1167,6 +1188,13 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec std::vector mRunColours; std::vector mRunColourAt; std::vector mSquiggles; + // A frame's carets, drawn together once the rows are. + struct CaretBox + { + LLRect rect; + LLColor4 color; + }; + std::vector mCaretBoxes; // What the band under the text shows and where its pieces go, as the // keymap had it at its generation in this font: read and measured // again only when the keymap has moved on. diff --git a/indra/llui/tests/altextcarets_test.cpp b/indra/llui/tests/altextcarets_test.cpp new file mode 100644 index 0000000000..18a7966532 --- /dev/null +++ b/indra/llui/tests/altextcarets_test.cpp @@ -0,0 +1,218 @@ +/** + * @file tests/altextcarets_test.cpp + * @brief The selections a text view has besides its main one. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../altextcarets.h" + +#include "../test/lltut.h" + +#include +#include + +namespace +{ + ALTextRange caret(S32 line, S32 column) + { + return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); + } + + // From an anchor to a caret, on one line. + ALTextRange sel(S32 line, S32 anchor, S32 caret_column) + { + return ALTextRange(ALTextPos(line, anchor), ALTextPos(line, caret_column)); + } + + std::string said(const ALTextRange& r) + { + return std::to_string(r.begin.line) + ":" + std::to_string(r.begin.column) + "-" + std::to_string(r.end.line) + ":" + + std::to_string(r.end.column); + } + + std::string said(const std::vector& all) + { + std::string out; + for (const ALTextRange& r : all) + { + out += (out.empty() ? "" : " ") + said(r); + } + return out; + } +} + +namespace tut +{ + struct altextcarets_data + { + }; + typedef test_group altextcarets_group; + typedef altextcarets_group::object altextcarets_object; + altextcarets_group altextcarets_instance("altextcarets"); + + template<> template<> + void altextcarets_object::test<1>() + { + set_test_name("a caret is pushed past what is typed at it; a selection keeps what it held, what goes in at either edge outside it"); + ALTextDocument doc("abcdef"); + // Typed at 2. + ALTextDocument::Edit typed = doc.insert(ALTextPos(0, 2), "XY"); + ensure_equals("a caret at it pushed past", said(ALTextCarets::slid(caret(0, 2), typed)), said(caret(0, 4))); + ensure_equals("one before it stays", said(ALTextCarets::slid(caret(0, 1), typed)), said(caret(0, 1))); + ensure_equals("one after it moves along", said(ALTextCarets::slid(caret(0, 5), typed)), said(caret(0, 7))); + ensure_equals("a selection that begins at it is pushed whole", said(ALTextCarets::slid(sel(0, 2, 4), typed)), said(sel(0, 4, 6))); + ensure_equals("one that ends at it stays, what went in after it", said(ALTextCarets::slid(sel(0, 0, 2), typed)), said(sel(0, 0, 2))); + ensure_equals("and backwards the same, the caret still at its start", said(ALTextCarets::slid(sel(0, 2, 0), typed)), said(sel(0, 2, 0))); + ensure_equals("one over it grows by it", said(ALTextCarets::slid(sel(0, 1, 3), typed)), said(sel(0, 1, 5))); + ensure_equals("and keeps its way", said(ALTextCarets::slid(sel(0, 3, 1), typed)), said(sel(0, 5, 1))); + + // "abXYcdef": "Ycd" taken. + ALTextDocument::Edit taken = doc.remove(ALTextRange(ALTextPos(0, 3), ALTextPos(0, 6))); + ensure_equals("a caret in what was taken goes to where it began", said(ALTextCarets::slid(caret(0, 5), taken)), said(caret(0, 3))); + ensure_equals("a selection over its start is cut back to it", said(ALTextCarets::slid(sel(0, 1, 4), taken)), said(sel(0, 1, 3))); + ensure_equals("one over its end loses what it took", said(ALTextCarets::slid(sel(0, 7, 4), taken)), said(sel(0, 4, 3))); + ensure_equals("one inside it a caret where it began", said(ALTextCarets::slid(sel(0, 4, 5), taken)), said(caret(0, 3))); + } + + template<> template<> + void altextcarets_object::test<2>() + { + set_test_name("those that overlap become one, and so do a caret and what it touches; two selections that only touch stay two"); + ALTextCarets carets; + carets.assign({ sel(0, 0, 3), sel(0, 3, 6) }); + ensure_equals("two side by side stay two", said(carets.selections()), said(std::vector{ sel(0, 0, 3), sel(0, 3, 6) })); + carets.assign({ sel(0, 0, 4), sel(0, 3, 6) }); + ensure_equals("two over each other one", said(carets.selections()), said(sel(0, 0, 6))); + carets.assign({ sel(0, 0, 3), caret(0, 3) }); + ensure_equals("a caret at a selection's end in it", said(carets.selections()), said(sel(0, 0, 3))); + carets.assign({ caret(1, 2), sel(1, 6, 2) }); + ensure_equals("one at its start too, the selection's way kept", said(carets.selections()), said(sel(1, 6, 2))); + carets.assign({ caret(2, 1), caret(2, 1), caret(0, 1) }); + ensure_equals("two carets at one place one, in order", said(carets.selections()), said(std::vector{ caret(0, 1), caret(2, 1) })); + carets.assign({ sel(0, 0, 2), sel(0, 1, 4), sel(0, 3, 8), caret(0, 10) }); + ensure_equals("a run of overlaps one, the one after them not", said(carets.selections()), + said(std::vector{ sel(0, 0, 8), caret(0, 10) })); + carets.add(caret(0, 8)); + ensure_equals("one more added merges as it goes in", said(carets.selections()), said(std::vector{ sel(0, 0, 8), caret(0, 10) })); + carets.add(caret(0, 9)); + ensure_equals("and one in a gap stays", carets.size(), static_cast(3)); + } + + template<> template<> + void altextcarets_object::test<3>() + { + set_test_name("the main selection takes in any it meets, and keeps its way; a caret as main takes the way of what it took in"); + ALTextCarets carets; + carets.assign({ caret(0, 2), sel(0, 5, 8), caret(3, 0) }); + ALTextRange main = sel(0, 5, 0); + ensure("the main one as it was", !carets.merge(main)); + ensure_equals("and still so", said(main), said(sel(0, 5, 0))); + ensure_equals("the caret it covers taken in, not the selection it only touches", said(carets.selections()), + said(std::vector{ sel(0, 5, 8), caret(3, 0) })); + + main = sel(0, 2, 6); + ensure("changed again", carets.merge(main)); + ensure_equals("over the selection now, its own way kept", said(main), said(sel(0, 2, 8))); + ensure_equals("the others left", said(carets.selections()), said(caret(3, 0))); + + carets.assign({ sel(1, 9, 3) }); + main = caret(1, 9); + ensure("a caret at a selection's end changed", carets.merge(main)); + ensure_equals("it is the selection, run its way", said(main), said(sel(1, 9, 3))); + ensure("nothing else", carets.empty()); + + carets.assign({ caret(5, 0) }); + main = caret(0, 0); + ensure("apart, nothing changes", !carets.merge(main)); + ensure_equals("and nothing goes", carets.size(), static_cast(1)); + } + + template<> template<> + void altextcarets_object::test<4>() + { + set_test_name("an edit slides each along, those it brought together merged; those before it and below it untouched"); + ALTextDocument doc("one two three\nfour five\nsix"); + ALTextCarets carets; + carets.assign({ caret(0, 1), caret(0, 5), sel(0, 8, 11), caret(1, 2), caret(2, 1) }); + // "two thr" taken: the caret in it and the selection's start go to + // where it began, and meet. + carets.apply(doc.remove(ALTextRange(ALTextPos(0, 4), ALTextPos(0, 11)))); + ensure_equals("cut back and merged", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), caret(1, 2), caret(2, 1) })); + // A line put in above the second: those below move down a line. + carets.apply(doc.insert(ALTextPos(1, 0), "new\n")); + ensure_equals("moved down", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), caret(2, 2), caret(3, 1) })); + // A line joined to the one before: the caret on it goes onto that one. + carets.apply(doc.remove(ALTextRange(ALTextPos(2, 4), ALTextPos(3, 0)))); + ensure_equals("onto the line it was joined to", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), caret(2, 2), caret(2, 5) })); + ensure_equals("the text as the carets have it", doc.text(), std::string("one ee\nnew\nfoursix")); + } + + template<> template<> + void altextcarets_object::test<5>() + { + set_test_name("a batch's stretches slide each by the stretches before it; a caret in one goes to where it begins"); + ALTextDocument doc("a b c d e"); + ALTextCarets carets; + carets.assign({ caret(0, 1), caret(0, 3), sel(0, 4, 5), caret(0, 9) }); + // "b" made "BB", and "d" made "" -- the caret at 3 is right after b. + carets.apply(doc.replaceMany({ { ALTextRange(ALTextPos(0, 2), ALTextPos(0, 3)), "BB" }, { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 7)), "" } })); + ensure_equals("each along by what went before it", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), sel(0, 5, 6), caret(0, 9) })); + ensure_equals("the text", doc.text(), std::string("a BB c e")); + // Text put in at the selection's start and at its end: it keeps + // what it held; the caret at the end of the text pushed. + carets.apply(doc.replaceMany({ { ALTextRange(ALTextPos(0, 5), ALTextPos(0, 5)), "<" }, { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 6)), ">" }, + { ALTextRange(ALTextPos(0, 9), ALTextPos(0, 9)), "!" } })); + ensure_equals("the selection still over c alone, the last caret past the !", said(carets.selections()), + said(std::vector{ caret(0, 1), caret(0, 4), sel(0, 6, 7), caret(0, 12) })); + ensure_equals("the text again", doc.text(), std::string("a BB e!")); + } + + template<> template<> + void altextcarets_object::test<6>() + { + set_test_name("those that may lie on a line: one that began above and reaches it, and those that begin on it"); + ALTextCarets carets; + carets.assign({ caret(0, 0), ALTextRange(ALTextPos(4, 2), ALTextPos(1, 3)), caret(2, 0), caret(2, 4), caret(3, 1) }); + const auto on = [&](S32 line) { + std::vector out; + for (auto [it, end] = carets.onLine(line); it != end; ++it) + { + const ALTextRange r = it->normalised(); + if (r.begin.line <= line && line <= r.end.line) + { + out.push_back(*it); + } + } + return said(out); + }; + // The long one takes in what it covers. + ensure_equals("all, merged", carets.size(), static_cast(2)); + ensure_equals("line 0", on(0), said(caret(0, 0))); + ensure_equals("line 2 is the long one's", on(2), said(ALTextRange(ALTextPos(4, 2), ALTextPos(1, 3)))); + ensure_equals("line 5 nothing", on(5), std::string()); + } +} diff --git a/indra/llui/tests/altextundo_test.cpp b/indra/llui/tests/altextundo_test.cpp index 8fe914af2a..6d692ef7e9 100644 --- a/indra/llui/tests/altextundo_test.cpp +++ b/indra/llui/tests/altextundo_test.cpp @@ -777,4 +777,69 @@ namespace tut undo.undo(); ensure_equals("not the other's", doc.text(), std::string("qpsx12abcy")); } + + template<> template<> + void altextundo_object::test<24>() + { + set_test_name("a step keeps the other selections before and after: a run the first's before and the last's after; written and read back"); + doc.setText("abc\nabc\nabc"); + const auto at = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + const auto said = [](const std::vector& all) { + std::string out; + for (const ALTextRange& r : all) + { + out += llformat("%d:%d-%d:%d ", r.begin.line, r.begin.column, r.end.line, r.end.column); + } + return out; + }; + // Typed at the main caret, with others on the lines below: each + // edit noted with the others as they stood, and settled with them + // as they stand after. + now += 0.1; + ALTextDocument::Edit first = doc.insert(ALTextPos(0, 1), "x"); + undo.record(first, at(0, 1), first.endAfter(), now, { at(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 0)) }); + undo.settle(at(0, 2), { at(1, 2), ALTextRange(ALTextPos(2, 4), ALTextPos(2, 0)) }); + now += 0.1; + ALTextDocument::Edit second = doc.insert(ALTextPos(0, 2), "y"); + undo.record(second, at(0, 2), second.endAfter(), now, { at(1, 2) }); + undo.settle(at(0, 3), { at(1, 3) }); + // Said again without them: they stay as they were said. + undo.settle(at(0, 3)); + + std::vector others; + std::optional back = undo.undo(&others); + ensure("one step", back.has_value() && doc.text() == "abc\nabc\nabc"); + ensure_equals("the others as they stood before the run", said(others), said({ at(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 0)) })); + std::optional ahead = undo.redo(&others); + ensure("and forward", ahead.has_value() && *ahead == at(0, 3)); + ensure_equals("as they stood after it", said(others), said({ at(1, 3) })); + + // Written out and read back over the same text. + ALTextDocument copy(doc.text()); + ALTextUndo again(copy); + ensure("read back", again.fromLLSD(undo.asLLSD())); + std::istringstream notation(undo.asNotation()); + LLSD written; + ensure("and in notation", LLSDSerialize::fromNotation(written, notation, LLSDSerialize::SIZE_UNLIMITED) > 0 && + written["undo"][0]["others_after"].size() == 1); + again.undo(&others); + ensure_equals("the others before, read back", said(others), said({ at(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 0)) })); + again.redo(&others); + ensure_equals("and after", said(others), said({ at(1, 3) })); + + // A step whose others after were never said: a redo leaves the + // caret alone; and one with none has none. + now += 5.0; + ALTextDocument::Edit third = doc.insert(ALTextPos(0, 0), "z"); + undo.record(third, at(0, 0), third.endAfter(), now, { at(2, 0) }); + undo.settle(at(0, 1)); + undo.undo(&others); + ensure_equals("before, as noted", said(others), said({ at(2, 0) })); + undo.redo(&others); + ensure("after, none", others.empty()); + now += 5.0; + type(ALTextPos(0, 0), "w"); + undo.undo(&others); + ensure("none where there were none", others.empty()); + } } diff --git a/indra/llui/tests/altextview_gl_test.cpp b/indra/llui/tests/altextview_gl_test.cpp index 92d1ea0920..89990c8682 100644 --- a/indra/llui/tests/altextview_gl_test.cpp +++ b/indra/llui/tests/altextview_gl_test.cpp @@ -501,4 +501,110 @@ namespace tut ensure("drawn under the text", drawn == 255); ensure("only the fade just under the baseline: " + std::to_string(under), under < 128); } + + // The carets besides the main one are drawn as it is, blinking with it: + // a frame with the main caret on one line and another on a second is + // the frame with the two the other way round. A selection besides the + // main one is washed as it is, on its own line only. And every caret + // in sight is the one draw. + template<> template<> + void altextview_gl_object::test<8>() + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(/*gl_textures=*/true); + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + std::string text; + for (S32 i = 0; i < 12; ++i) + { + text += "integer value = f(x, [y, z]);\n"; + } + ALTextView::Params p(LLUICtrlFactory::getDefaultParams()); + p.name = "view"; + p.rect = LLRect(0, H, W, 0); + p.default_text = text; + // The skin's colours are not loaded here: the caret's is given. + p.cursor_color = LLUIColor(LLColor4::white); + ALTextView* view = LLUICtrlFactory::create(p); + view->setFont(LLFontGL::getFontMonospace()); + view->setSelectionColor(LLUIColor(LLColor4(0.2f, 0.4f, 0.9f, 0.6f))); + // Shown, and still from frame to frame. + view->setCaretBlink(false); + view->setFocus(true); + + const auto frame = [&]() { + gl().clearFramebuffer(); + glEnable(GL_BLEND); + gGL.setSceneBlendType(LLRender::BT_ALPHA); + view->draw(); + gGL.flush(); + glDisable(GL_BLEND); + glFinish(); + return ll_test::readFramebufferRGBA(W, H); + }; + // Every pixel that differs between two frames lies within a line's + // band, and some do. + const auto only_on = [&](const std::string& what, const std::vector& a, const std::vector& b, S32 line) { + const S32 top = view->textRect().mTop - (view->layout().lineTop(line) - view->scrollY()) + 3; + const S32 bottom = top - 3 - view->layout().lineHeight(line) - 3; + bool some = false; + for (S32 y = 0; y < H; ++y) + { + for (S32 x = 0; x < W; ++x) + { + const size_t i = (static_cast(y) * W + x) * 4; + if (a[i] != b[i] || a[i + 1] != b[i + 1] || a[i + 2] != b[i + 2]) + { + some = true; + ensure(what + ": changed at " + std::to_string(x) + "," + std::to_string(y) + ", off line " + std::to_string(line), + y <= top && y >= bottom); + } + } + } + ensure(what + ": something changed", some); + }; + const auto at = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + + view->setSelection(at(4, 0)); + const std::vector one = frame(); + view->addSelection(at(2, 5)); + const std::vector two = frame(); + only_on("another caret", one, two, 2); + view->setSelections(at(2, 5), { at(4, 0) }); + ensure("the two the other way round, the same frame", frame() == two); + + const ALTextRange span(ALTextPos(7, 2), ALTextPos(7, 10)); + view->setSelections(at(4, 0), { span }); + const std::vector other_span = frame(); + only_on("another selection, its caret at its end", one, other_span, 7); + view->setSelections(span, { at(4, 0) }); + ensure("the same as the main selection", frame() == other_span); + + // A caret on every line: the carets are one draw, not one each. + std::vector many; + for (S32 line = 1; line < 12; ++line) + { + many.push_back(at(line, line)); + } + view->setSelections(at(0, 0), many); + std::list capture; + gGL.beginList(&capture); + view->draw(); + gGL.flush(); + gGL.endList(); + glFinish(); + const size_t with_many = capture.size(); + capture.clear(); + view->singleSelection(); + gGL.beginList(&capture); + view->draw(); + gGL.flush(); + gGL.endList(); + glFinish(); + ensure("twelve carets cost what one does: " + std::to_string(with_many) + " against " + std::to_string(capture.size()), + with_many == capture.size()); + gFocusMgr.setKeyboardFocus(nullptr); + view->die(); + } } diff --git a/indra/llui/tests/altextview_test.cpp b/indra/llui/tests/altextview_test.cpp index 70d9fe748f..268ea4a1c3 100644 --- a/indra/llui/tests/altextview_test.cpp +++ b/indra/llui/tests/altextview_test.cpp @@ -1927,4 +1927,84 @@ namespace tut ensure("let go", gFocusMgr.getMouseCapture() == nullptr && !ruler->dragging()); ensure("no tip on the map", v.handleToolTip(360, 100, MASK_NONE)); } + + // Ranges as text, for a failure to say what it found. + static std::string rangesSaid(const std::vector& all) + { + std::string out; + for (const ALTextRange& r : all) + { + out += llformat("%d:%d-%d:%d ", r.begin.line, r.begin.column, r.end.line, r.end.column); + } + return out; + } + + static ALTextRange caretRange(S32 line, S32 column) + { + return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); + } + + template<> template<> + void altextview_object::test<61>() + { + set_test_name("the selections besides the main one slide with an edit at the main caret, are taken in where it meets them, and go with a new text"); + ALTextView& v = make("alpha beta\ngamma delta\n"); + v.setCaret(ALTextPos(0, 0)); + v.addSelection(caretRange(1, 0)); + v.addSelection(ALTextRange(ALTextPos(1, 6), ALTextPos(1, 11))); + ensure_equals("two besides it", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(1, 0), ALTextRange(ALTextPos(1, 6), ALTextPos(1, 11)) })); + ensure("the main one where it was", v.selection() == caretRange(0, 0)); + + type("xy"); + key(KEY_RETURN); + ensure_equals("typed at the main caret only", v.text(), std::string("xy\nalpha beta\ngamma delta\n")); + ensure_equals("the others a line down", rangesSaid(v.otherSelections()), + rangesSaid({ caretRange(2, 0), ALTextRange(ALTextPos(2, 6), ALTextPos(2, 11)) })); + + // The main one selected backwards over part of the other selection: + // one selection over both, run the main one's way. + v.setSelection(ALTextRange(ALTextPos(2, 8), ALTextPos(2, 3))); + ensure_equals("the main one over both, backwards", rangesSaid({ v.selection() }), rangesSaid({ ALTextRange(ALTextPos(2, 11), ALTextPos(2, 3)) })); + ensure_equals("the caret it does not reach left", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(2, 0) })); + + // One added where the main one is: nothing more. + v.addSelection(caretRange(2, 5)); + ensure_equals("taken in", v.otherSelections().size(), static_cast(1)); + ensure("one caret again", v.singleSelection() && !v.hasOtherSelections()); + ensure("and nothing to let go of now", !v.singleSelection()); + + v.setSelections(caretRange(0, 0), { caretRange(1, 1) }); + v.setText("new"); + ensure("none with a new text", !v.hasOtherSelections()); + } + + template<> template<> + void altextview_object::test<62>() + { + set_test_name("undo puts the other selections back as they were before the step, and redo as they were after it"); + ALTextView& v = make("one\ntwo\nthree\n"); + const std::vector before = { caretRange(1, 3), ALTextRange(ALTextPos(2, 5), ALTextPos(2, 0)) }; + v.setSelections(caretRange(0, 3), before); + v.insertText("X\n"); + const std::vector after = { caretRange(2, 3), ALTextRange(ALTextPos(3, 5), ALTextPos(3, 0)) }; + ensure_equals("slid down a line", rangesSaid(v.otherSelections()), rangesSaid(after)); + + v.undo(); + ensure_equals("the text back", v.text(), std::string("one\ntwo\nthree\n")); + ensure("the main caret back", v.selection() == caretRange(0, 3)); + ensure_equals("the others as they were before", rangesSaid(v.otherSelections()), rangesSaid(before)); + v.redo(); + ensure("the main caret after", v.selection() == caretRange(1, 0)); + ensure_equals("the others as they were after", rangesSaid(v.otherSelections()), rangesSaid(after)); + + // A step made with none: undone, none; the one before it, the + // others it had. + v.singleSelection(); + v.undoJournal().breakRun(); + v.insertText("Y"); + v.undo(); + ensure("none before a step made with none", !v.hasOtherSelections()); + v.undo(); + ensure_equals("and the step before puts its own back", rangesSaid(v.otherSelections()), rangesSaid(before)); + } } From 156b8cc85d3a20bcb9dbf3494ef592e9e5795685 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 14:03:21 -0400 Subject: [PATCH 02/41] Keys act at every caret where a text view has several Typing, Backspace and Delete, Return with its indent, Tab and Shift-Tab, comments, and the commands over lines -- duplicate, move, delete, join, a line put in above or below, the lines selected -- act at every selection, each worked out over the text as it stands before any of them and made as one edit, one step to undo. A command over lines acts once on lines several selections share, and moving or deleting takes lines next to each other as one. A run of Backspaces at several carets is one step, apart from the typing before it. The caret's motions move every caret, each keeping its own column between rows, and carets a motion brings together become one. Copy and cut take the selections one to a line; a paste gives each selection a line where the clip has one for each, else the whole clip. A command that acts at one place -- finding, going to a definition, completing, the next function -- acts at the main caret and lets the others go. The code editor closes, types over and takes away its pairs at every caret, and wraps every selection; what its typing puts up -- the list, the signature, a snippet's stops -- goes while there are several. Its typed-over closers and its inlay hints slide by each stretch of an edit of several, not by the whole span, which had dropped those between them. Co-Authored-By: Claude Opus 5.5 --- indra/llui/alcodeeditor.cpp | 271 +++++-- indra/llui/alcodeeditor.h | 22 +- indra/llui/altextdocument.cpp | 11 + indra/llui/altextdocument.h | 6 + indra/llui/altextediting.cpp | 477 +++++++++++-- indra/llui/altextediting.h | 60 ++ indra/llui/altextindent.cpp | 67 +- indra/llui/altextindent.h | 5 + indra/llui/altextundo.cpp | 9 +- indra/llui/altextundo.h | 7 +- indra/llui/altextview.cpp | 858 ++++++++++++++++++++--- indra/llui/altextview.h | 75 +- indra/llui/tests/alcodeeditor_test.cpp | 29 + indra/llui/tests/altextdocument_test.cpp | 22 + indra/llui/tests/altextediting_test.cpp | 131 ++++ indra/llui/tests/altextindent_test.cpp | 33 + indra/llui/tests/altextview_test.cpp | 182 ++++- 17 files changed, 1993 insertions(+), 272 deletions(-) diff --git a/indra/llui/alcodeeditor.cpp b/indra/llui/alcodeeditor.cpp index 30e7928dfe..857ddc3cb4 100644 --- a/indra/llui/alcodeeditor.cpp +++ b/indra/llui/alcodeeditor.cpp @@ -267,7 +267,6 @@ void ALCodeEditor::onEdit(const ALTextDocument::Edit& edit) // Decorations and highlights after the edit move along with the text; // the ones it cut into go. - const ALTextRange removed = edit.range.normalised(); mDecorations.apply(edit); for (auto& layer : mHighlights) { @@ -279,23 +278,16 @@ void ALCodeEditor::onEdit(const ALTextDocument::Edit& edit) // is typed is the start of that text; one after the text before it // moves along, since what is typed extends that text. An edit that // takes the position with it takes the inlay. + // A batch's stretches each so, the text between them untouched. mInlays.apply( edit, - [&removed](InlayHint& h, const ALTextDocument::Edit& e) { - if (removed.empty()) + [](InlayHint& h, const ALTextDocument::Edit& e) { + if (const std::optional taken = e.replacedAround(h.at); taken && taken->begin < h.at) { - if (removed.begin < h.at || (removed.begin == h.at && !h.before)) - { - h.at = e.slidPast(h.at); - } - return true; - } - if (removed.end <= h.at) - { - h.at = e.slidPast(h.at); - return true; + return false; } - return h.at <= removed.begin; + h.at = e.placed(h.at, !h.before); + return true; }, [](InlayHint&) {}); // The stops of a snippet or a call being filled in move with the text. @@ -320,13 +312,13 @@ void ALCodeEditor::onEdit(const ALTextDocument::Edit& edit) // an edit that takes it. mAutoClosed.apply( edit, - [&removed](ALTextPos& at, const ALTextDocument::Edit& e) { - if (removed.end <= at) + [](ALTextPos& at, const ALTextDocument::Edit& e) { + if (e.replacedAround(at)) { - at = e.slidPast(at); - return true; + return false; } - return at < removed.begin; + at = e.placed(at); + return true; }, [](ALTextPos&) {}); @@ -4157,6 +4149,12 @@ bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) } }); hideCard(); + // At several carets, nothing the typing puts up -- the list, the + // signature, a snippet's stops -- is in play: each is about one place. + if (hasOtherSelections() && typingText()) + { + dropTyping(); + } // The fixes listed take the keys that walk them and take one, in any // mode a modal keymap is in -- the list was asked for; any other key // lets them go and is the text's. @@ -4309,7 +4307,7 @@ bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) taking = at >= 0 && at < static_cast(line.size()) ? line[static_cast(at)] : 0; } const bool call_changes = hasSelection() || taking == '(' || taking == ',' || taking == ')'; - if (key == KEY_BACKSPACE && mask == MASK_NONE && mAutoClose && deletePair()) + if (key == KEY_BACKSPACE && mask == MASK_NONE && mAutoClose && (hasOtherSelections() ? deletePairs() : deletePair())) { if (mCards.signature() && mSignatureRequest && call_changes) { @@ -4334,6 +4332,17 @@ bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) bool ALCodeEditor::handleUnicodeCharHere(llwchar uni_char) { const bool typing = typingText(); + if (typing && hasOtherSelections()) + { + // At every caret: the list, the signature and a snippet's stops are + // each about one place, and go. + dropTyping(); + undoJournal().beginTyping(selection()); + const bool typed = + (mAutoClose && uni_char < 0x80 && !isReadOnly() && typePairs(static_cast(uni_char))) || ALTextView::handleUnicodeCharHere(uni_char); + undoJournal().endTyping(); + return typed; + } const bool was_open = completionOpen(); // The pair, the character and its outdent one key typed, one with the // typing around it -- and every place of a text being changed at once @@ -4388,33 +4397,28 @@ bool ALCodeEditor::handleUnicodeCharHere(llwchar uni_char) return true; } -bool ALCodeEditor::typePair(char c) +std::optional ALCodeEditor::pairAt(const ALTextRange& selection, char c) { const std::shared_ptr grammar = highlighter().grammar(); if (!grammar || grammar->pairs().empty()) { - return false; + return std::nullopt; } const auto& pairs = grammar->pairs(); - const ALTextPos at = caret(); + const ALTextPos at = selection.end; const std::string& line = document().line(at.line); const char next = at.column < static_cast(line.size()) ? line[at.column] : '\0'; // Over the closer typing put in, rather than a second one. - if (!hasSelection() && next == c) + if (selection.empty() && next == c && std::find(mAutoClosed.begin(), mAutoClosed.end(), at) != mAutoClosed.end()) { - const auto put = std::find(mAutoClosed.begin(), mAutoClosed.end(), at); - if (put != mAutoClosed.end()) + for (const auto& [open, close] : pairs) { - for (const auto& [open, close] : pairs) + if (close == c) { - if (close == c) - { - // Where the typing goes on from, for the key after. - mAutoClosed.erase(put); - setCaret(ALTextPos(at.line, at.column + 1)); - undoJournal().settle(selection()); - return true; - } + Paired paired; + paired.over = true; + paired.change.caret = ALTextPos(at.line, at.column + 1); + return paired; } } } @@ -4424,23 +4428,24 @@ bool ALCodeEditor::typePair(char c) { continue; } - if (hasSelection()) + if (!selection.empty()) { // The selection wrapped in the pair, and still chosen inside it. - const ALTextRange sel = selection().normalised(); - const std::string inner = document().text(sel); - insertText(std::string(1, open) + inner + std::string(1, close)); - const ALTextPos end = caret(); - setSelection(ALTextRange(ALTextPos(sel.begin.line, sel.begin.column + 1), ALTextPos(end.line, end.column - 1))); - undoJournal().settle(selection()); - return true; + const ALTextRange sel = selection.normalised(); + const std::string inner = std::string(1, open) + document().text(sel); + Paired paired; + paired.change.replacements.push_back({ sel, inner + std::string(1, close) }); + paired.change.selects = true; + paired.change.anchor = ALTextPos(sel.begin.line, sel.begin.column + 1); + paired.change.caret = ALTextEditing::endOf(sel.begin, inner); + return paired; } // Not in a comment or a string, where it is prose; and only before // a blank, the line's end, or what closes or ends -- a bracket // opened before a word is about that word. if (inProse(at)) { - return false; + return std::nullopt; } bool room = next == '\0' || isspace(static_cast(next)) || strchr(";,", next) != nullptr; for (const auto& [o, closer] : pairs) @@ -4449,49 +4454,180 @@ bool ALCodeEditor::typePair(char c) } if (!room) { - return false; + return std::nullopt; } // A quote after a letter is an apostrophe, and after itself is the // end of an empty string. if (open == close && at.column > 0 && (alIdentifierByte(line[at.column - 1]) || line[at.column - 1] == open)) { - return false; + return std::nullopt; } - insertText(std::string(1, open) + std::string(1, close)); - const ALTextPos inside(at.line, at.column + 1); - setCaret(inside); + Paired paired; + paired.change.replacements.push_back({ ALTextRange(at, at), std::string(1, open) + std::string(1, close) }); + paired.change.caret = ALTextPos(at.line, at.column + 1); + paired.closes = true; + return paired; + } + return std::nullopt; +} + +bool ALCodeEditor::typePair(char c) +{ + const std::optional paired = pairAt(selection(), c); + if (!paired) + { + return false; + } + if (paired->over) + { + // Where the typing goes on from, for the key after. + mAutoClosed.eraseIf([at = caret()](const ALTextPos& closer) { return closer == at; }); + setCaret(paired->change.caret); undoJournal().settle(selection()); - mAutoClosed.insert(inside); return true; } - return false; + insertText(paired->change.replacements.front().text); + if (paired->change.selects) + { + setSelection(ALTextRange(paired->change.anchor, paired->change.caret)); + } + else + { + setCaret(paired->change.caret); + } + undoJournal().settle(selection()); + if (paired->closes) + { + mAutoClosed.insert(caret()); + } + return true; } -bool ALCodeEditor::deletePair() +bool ALCodeEditor::typePairs(char c) { - const std::shared_ptr grammar = highlighter().grammar(); - const ALTextPos at = caret(); - const auto put = std::find(mAutoClosed.begin(), mAutoClosed.end(), at); - if (!grammar || hasSelection() || put == mAutoClosed.end() || at.column == 0) + // Worked out at each before any is made. + const std::vector all = selectionsInOrder(); + std::vector> paired; + paired.reserve(all.size()); + bool any = false; + for (const ALTextRange& one : all) + { + paired.push_back(pairAt(one, c)); + any = any || paired.back().has_value(); + } + if (!any) { return false; } + // The closers typed over are let go of before the edit moves them. + for (size_t i = 0; i < all.size(); ++i) + { + if (paired[i] && paired[i]->over) + { + mAutoClosed.eraseIf([at = all[i].end](const ALTextPos& closer) { return closer == at; }); + } + } + const std::string plain(1, c); + editEach([&](size_t i, const ALTextRange& selection) { + if (paired[i]) + { + return std::optional(paired[i]->change); + } + const ALTextRange over = selection.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, plain }); + one.caret = ALTextEditing::endOf(over.begin, plain); + return std::optional(std::move(one)); + }); + // Each closer put in, before the caret that was put inside its pair. + const std::vector now = selectionsInOrder(); + if (now.size() == all.size()) + { + for (size_t i = 0; i < now.size(); ++i) + { + if (paired[i] && paired[i]->closes) + { + mAutoClosed.insert(now[i].end); + } + } + } + return true; +} + +std::optional ALCodeEditor::pairAround(const ALTextPos& at) +{ + const std::shared_ptr grammar = highlighter().grammar(); + if (!grammar || at.column == 0 || std::find(mAutoClosed.begin(), mAutoClosed.end(), at) == mAutoClosed.end()) + { + return std::nullopt; + } const std::string& line = document().line(at.line); if (at.column >= static_cast(line.size())) { - return false; + return std::nullopt; } for (const auto& [open, close] : grammar->pairs()) { if (line[at.column - 1] == open && line[at.column] == close) { - mAutoClosed.erase(put); - setSelection(ALTextRange(ALTextPos(at.line, at.column - 1), ALTextPos(at.line, at.column + 1))); - insertText(std::string()); - return true; + return ALTextRange(ALTextPos(at.line, at.column - 1), ALTextPos(at.line, at.column + 1)); } } - return false; + return std::nullopt; +} + +bool ALCodeEditor::deletePair() +{ + if (hasSelection()) + { + return false; + } + const std::optional pair = pairAround(caret()); + if (!pair) + { + return false; + } + mAutoClosed.eraseIf([at = caret()](const ALTextPos& closer) { return closer == at; }); + setSelection(*pair); + insertText(std::string()); + return true; +} + +bool ALCodeEditor::deletePairs() +{ + const std::vector all = selectionsInOrder(); + std::vector> pairs; + pairs.reserve(all.size()); + bool any = false; + for (const ALTextRange& one : all) + { + pairs.push_back(one.empty() ? pairAround(one.end) : std::nullopt); + any = any || pairs.back().has_value(); + if (pairs.back()) + { + mAutoClosed.eraseIf([at = one.end](const ALTextPos& closer) { return closer == at; }); + } + } + if (!any) + { + return false; + } + // One Backspace at each: the pair where there is one, else what a + // Backspace takes there. + undoJournal().beginTyping(selection(), true); + editEach([&](size_t i, const ALTextRange& selection) -> std::optional { + const std::optional range = pairs[i] ? pairs[i] : erasedBy(ALEditorCommand::DeleteLeft, selection); + if (!range || range->empty()) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back({ range->normalised(), std::string() }); + one.caret = range->normalised().begin; + return one; + }); + undoJournal().endTyping(); + return true; } bool ALCodeEditor::handleMouseDown(S32 x, S32 y, MASK mask) @@ -4818,8 +4954,15 @@ void ALCodeEditor::draw() { hideSignature(); } - // A closer put in is typed over only on its own line. - mAutoClosed.eraseIf([this](const ALTextPos& at) { return at.line != caret().line; }); + // A closer put in is typed over only on its own line: a caret's. + mAutoClosed.eraseIf([this](const ALTextPos& at) { + if (at.line == caret().line) + { + return false; + } + const std::vector& others = otherSelections(); + return std::none_of(others.begin(), others.end(), [&at](const ALTextRange& other) { return other.end.line == at.line; }); + }); // The name under the caret lit once the caret has rested, and again // where the view has scrolled past the lines it was lit over. if (mLightsOccurrences && diff --git a/indra/llui/alcodeeditor.h b/indra/llui/alcodeeditor.h index de33571d5f..f6b0629d0f 100644 --- a/indra/llui/alcodeeditor.h +++ b/indra/llui/alcodeeditor.h @@ -838,11 +838,29 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures bool hoverCardAt(S32 x, S32 y); // A pair typed as it was typed: the opener closed, the closer typed // over, a selection wrapped. False where the character is for the - // text as ever. + // text as ever. At every selection where there are several, each as + // it would be on its own, a character as ever at those it pairs + // nothing at; false where it pairs nothing at any. bool typePair(char c); + bool typePairs(char c); + // What a character typed at a selection does as a pair: what it + // replaces and where the selection goes, as a change; whether that + // goes over a closer typing put in, or puts one in at the caret after. + // Nothing where the character is for the text as ever. + struct Paired + { + ALTextEditing::Change change; + bool over = false; + bool closes = false; + }; + std::optional pairAt(const ALTextRange& selection, char c); // The pair around the caret that one Backspace takes away, if the - // closer is one typing put in. + // closer is one typing put in: at the caret, or as one Backspace at + // every caret where there are several, the pairs at those it finds + // one at. False where it finds none. bool deletePair(); + bool deletePairs(); + std::optional pairAround(const ALTextPos& at); // The problems squiggled under a position, and the stretch they span. std::vector problemsUnder(const ALTextPos& at, ALTextRange& about) const; void vocabularyCompletions(std::string_view prefix, std::vector& out); diff --git a/indra/llui/altextdocument.cpp b/indra/llui/altextdocument.cpp index ba0479cd60..a15d6b0ef6 100644 --- a/indra/llui/altextdocument.cpp +++ b/indra/llui/altextdocument.cpp @@ -210,6 +210,17 @@ ALTextPos ALTextDocument::Edit::placed(const ALTextPos& pos, bool pushed) const return mapped(pos, pushed); } +std::optional ALTextDocument::Edit::replacedAround(const ALTextPos& pos) const +{ + if (parts.empty()) + { + const ALTextRange removed = range.normalised(); + return removed.begin <= pos && pos < removed.end ? std::optional(removed) : std::nullopt; + } + const auto in = std::upper_bound(parts.begin(), parts.end(), pos, [](const ALTextPos& p, const Part& part) { return p < part.before.end; }); + return in != parts.end() && in->before.begin <= pos ? std::optional(in->before) : std::nullopt; +} + S32 ALTextDocument::Edit::lineAfter(S32 line) const { const std::vector& spans = lineSpans(); diff --git a/indra/llui/altextdocument.h b/indra/llui/altextdocument.h index 96abcbecdd..0279d0df34 100644 --- a/indra/llui/altextdocument.h +++ b/indra/llui/altextdocument.h @@ -29,6 +29,7 @@ #include +#include #include #include #include @@ -180,6 +181,11 @@ class ALTextDocument // as typing pushes a caret, unless not `pushed`: the end of a // selection, which what is put in right after it is not part of. ALTextPos placed(const ALTextPos& pos, bool pushed = true) const; + // The stretch, as it was, whose text the character at a position + // stood in -- what was replaced from at or before it to past it -- + // or nothing. For a batch, its own stretch only, not the text + // between them. + std::optional replacedAround(const ALTextPos& pos) const; // Where a line is after, or -1 for one the edit replaced: one of a // run of lines, a line typed in included, is not the line it was. S32 lineAfter(S32 line) const; diff --git a/indra/llui/altextediting.cpp b/indra/llui/altextediting.cpp index 06d7007d0a..1fde4d6b4b 100644 --- a/indra/llui/altextediting.cpp +++ b/indra/llui/altextediting.cpp @@ -27,6 +27,8 @@ #include "altextediting.h" +#include + namespace { bool isBlank(char c) @@ -44,6 +46,104 @@ namespace } return n; } + + // Lines first through last again, under them. + ALTextEditing::Replacement duplicated(const ALTextDocument& doc, S32 first, S32 last) + { + const std::string block = doc.text(ALTextRange(doc.lineStart(first), doc.lineEnd(last))); + return { ALTextRange(doc.lineEnd(last), doc.lineEnd(last)), "\n" + block }; + } + + // What deleting lines first through last takes -- the last line of the + // text with the break before it -- and the line a caret lands on once + // they are gone, and how long that is. + ALTextRange deleted(const ALTextDocument& doc, S32 first, S32 last, S32& line, S32& length) + { + const S32 count = doc.lineCount(); + ALTextRange range(doc.lineStart(first), last + 1 < count ? doc.lineStart(last + 1) : doc.lineEnd(last)); + line = first; + length = last + 1 < count ? doc.lineLength(last + 1) : 0; + if (last + 1 >= count && first > 0) + { + range.begin = doc.lineEnd(first - 1); + line = first - 1; + length = doc.lineLength(first - 1); + } + return range; + } + + // Whether every line first through last that says anything is + // commented with the token; nothing where none says anything. + std::optional commented(const ALTextDocument& doc, S32 first, S32 last, const std::string& token) + { + bool all_commented = true; + bool any_text = false; + for (S32 l = first; l <= last; ++l) + { + const std::string& line = doc.line(l); + const S32 n = indentationOf(line); + if (n == static_cast(line.size())) + { + continue; + } + any_text = true; + if (line.compare(n, token.size(), token) != 0) + { + all_commented = false; + } + } + if (!any_text) + { + return std::nullopt; + } + return all_commented; + } + + // Each line first through last that says anything commented out, the + // token and the space after it taken from where its text begins; or in, + // the token and a space put there. + std::vector commentLines(const ALTextDocument& doc, S32 first, S32 last, const std::string& token, bool out) + { + std::vector replacements; + for (S32 l = first; l <= last; ++l) + { + const std::string& line = doc.line(l); + const S32 n = indentationOf(line); + if (n == static_cast(line.size())) + { + continue; + } + if (out) + { + S32 taken = static_cast(token.size()); + if (n + taken < static_cast(line.size()) && line[n + taken] == ' ') + { + ++taken; + } + replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n + taken)), std::string() }); + } + else + { + replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n)), token + " " }); + } + } + return replacements; + } + + // A selection's lines whole, from the first's start to the next line's + // start, or to the end of the text's last line as the replacements + // leave it. + ALTextRange wholeLines(const ALTextDocument& doc, const ALTextRange& selection, const std::vector& replacements) + { + const auto [first, last] = ALTextEditing::selectedLines(selection); + return ALTextRange(ALTextPos(first, 0), last + 1 < doc.lineCount() ? ALTextPos(last + 1, 0) + : ALTextEditing::placedThrough(replacements, doc.lineEnd(last))); + } + + ALTextRange lineShifted(const ALTextRange& selection, S32 lines) + { + return ALTextRange(ALTextPos(selection.begin.line + lines, selection.begin.column), ALTextPos(selection.end.line + lines, selection.end.column)); + } } namespace ALTextEditing @@ -62,10 +162,9 @@ std::pair selectedLines(const ALTextRange& selection) Change duplicateLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret) { const auto [first, last] = selectedLines(ALTextRange(anchor, caret)); - const std::string block = doc.text(ALTextRange(doc.lineStart(first), doc.lineEnd(last))); const S32 count = last - first + 1; Change change; - change.replacements.push_back({ ALTextRange(doc.lineEnd(last), doc.lineEnd(last)), "\n" + block }); + change.replacements.push_back(duplicated(doc, first, last)); // The caret and the selection go with the copy. change.selects = true; change.anchor = ALTextPos(anchor.line + count, anchor.column); @@ -145,19 +244,11 @@ Change copyLinesTo(const ALTextDocument& doc, S32 first, S32 last, S32 below) Change deleteLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret) { const auto [first, last] = selectedLines(ALTextRange(anchor, caret)); - const S32 count = doc.lineCount(); - ALTextRange range(doc.lineStart(first), last + 1 < count ? doc.lineStart(last + 1) : doc.lineEnd(last)); // The line the caret lands on, and how long it is, once they are gone. - S32 line = first; - S32 length = last + 1 < count ? doc.lineLength(last + 1) : 0; - if (last + 1 >= count && first > 0) - { - // The last line goes with the newline before it. - range.begin = doc.lineEnd(first - 1); - line = first - 1; - length = doc.lineLength(first - 1); - } - Change change; + S32 line = first; + S32 length = 0; + const ALTextRange range = deleted(doc, first, last, line, length); + Change change; change.replacements.push_back({ range, std::string() }); change.caret = ALTextPos(line, llmin(caret.column, length)); change.anchor = change.caret; @@ -167,79 +258,28 @@ Change deleteLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALT std::optional toggleComment(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, const std::string& token) { const auto [first, last] = selectedLines(ALTextRange(anchor, caret)); - // Out where every line that says anything is commented; in otherwise. - bool all_commented = true; - bool any_text = false; - for (S32 l = first; l <= last; ++l) - { - const std::string& line = doc.line(l); - const S32 n = indentationOf(line); - if (n == static_cast(line.size())) - { - continue; - } - any_text = true; - if (line.compare(n, token.size(), token) != 0) - { - all_commented = false; - } - } - if (!any_text) + const std::optional out = commented(doc, first, last, token); + if (!out) { return std::nullopt; } Change change; - S32 caret_shift = 0; - // What the last line gained or lost, for where a selection of the - // lines whole ends. - S32 last_delta = 0; - for (S32 l = first; l <= last; ++l) - { - const std::string& line = doc.line(l); - const S32 n = indentationOf(line); - if (n == static_cast(line.size())) - { - continue; - } - S32 delta = 0; - if (all_commented) - { - S32 taken = static_cast(token.size()); - if (n + taken < static_cast(line.size()) && line[n + taken] == ' ') - { - ++taken; - } - change.replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n + taken)), std::string() }); - delta = -taken; - if (l == caret.line) - { - caret_shift = caret.column >= n + taken ? -taken : (caret.column > n ? n - caret.column : 0); - } - } - else - { - change.replacements.push_back({ ALTextRange(ALTextPos(l, n), ALTextPos(l, n)), token + " " }); - delta = static_cast(token.size()) + 1; - if (l == caret.line && caret.column >= n) - { - caret_shift = static_cast(token.size()) + 1; - } - } - if (l == last) - { - last_delta = delta; - } - } + change.replacements = commentLines(doc, first, last, token, *out); if (anchor != caret) { - change.selects = true; - change.anchor = ALTextPos(first, 0); - change.caret = last + 1 < doc.lineCount() ? ALTextPos(last + 1, 0) : ALTextPos(last, doc.lineLength(last) + last_delta); + // The lines whole. + const ALTextRange lines = wholeLines(doc, ALTextRange(anchor, caret), change.replacements); + change.selects = true; + change.anchor = lines.begin; + change.caret = lines.end; } else { - change.caret = ALTextPos(caret.line, caret.column + caret_shift); + // The caret where it was in its text: pushed along by the token put + // in where it stands, and back to where the token was taken from + // where it stood in it. + change.caret = placedThrough(change.replacements, caret); change.anchor = change.caret; } return change; @@ -271,4 +311,287 @@ std::optional joinLines(const ALTextDocument& doc, S32 first, S32 last, change.caret = doc.lineEnd(first); return change; } + +ALTextPos endOf(const ALTextPos& at, const std::string& text) +{ + const size_t last_break = text.rfind('\n'); + if (last_break == std::string::npos) + { + return ALTextPos(at.line, at.column + static_cast(text.size())); + } + return ALTextPos(at.line + static_cast(std::count(text.begin(), text.end(), '\n')), static_cast(text.size() - last_break - 1)); +} + +ALTextPos placedThrough(const std::vector& replacements, const ALTextPos& pos, bool pushed) +{ + // From the last, so that each is measured on the text as it was before + // any: those before it have not moved yet. + ALTextPos at = pos; + for (auto it = replacements.rbegin(); it != replacements.rend(); ++it) + { + ALTextDocument::Edit edit; + edit.range = it->range.normalised(); + edit.inserted = it->text; + at = edit.placed(at, pushed); + } + return at; +} + +Combined combine(std::vector groups, size_t count) +{ + Combined out; + out.selections.resize(count); + const auto by_begin = [](const Replacement& a, const Replacement& b) { return a.range.normalised().begin < b.range.normalised().begin; }; + for (Group& group : groups) + { + std::stable_sort(group.replacements.begin(), group.replacements.end(), by_begin); + } + // In the order of the text: where a group's first replacement begins, + // or for one that replaces nothing, where its first selection does -- + // before a group that replaces from the same place, its places being + // the text's as it stands. + const auto key = [](const Group& group) { + if (!group.replacements.empty()) + { + return group.replacements.front().range.normalised().begin; + } + ALTextPos first(S32_MAX, S32_MAX); + for (const auto& [index, selection] : group.placed) + { + first = std::min(first, selection.normalised().begin); + } + return first; + }; + std::vector order(groups.size()); + for (size_t i = 0; i < order.size(); ++i) + { + order[i] = i; + } + std::stable_sort(order.begin(), order.end(), [&](size_t a, size_t b) { + const ALTextPos ka = key(groups[a]); + const ALTextPos kb = key(groups[b]); + if (ka != kb) + { + return ka < kb; + } + return groups[a].replacements.empty() && !groups[b].replacements.empty(); + }); + + // Where each replacement kept so far ended, in the text before and in + // the text after: a place at or past one moves by the last of them as + // the place where it ended does (ALTextDocument::Edit's batch). + std::vector> ends; + const auto moved = [&ends](const ALTextPos& pos) { + const auto after = std::upper_bound(ends.begin(), ends.end(), pos, [](const ALTextPos& p, const std::pair& end) { return p < end.first; }); + if (after == ends.begin()) + { + return pos; + } + const auto& [before_end, after_end] = *(after - 1); + if (pos.line == before_end.line) + { + return ALTextPos(after_end.line, after_end.column + (pos.column - before_end.column)); + } + return ALTextPos(pos.line + (after_end.line - before_end.line), pos.column); + }; + for (const size_t g : order) + { + Group& group = groups[g]; + if (!group.replacements.empty() && !ends.empty() && group.replacements.front().range.normalised().begin < ends.back().first) + { + // Over a replacement before it: left out. + continue; + } + // Its selections, by the groups before it, which is all that has + // moved what its own replacements left. + for (const auto& [index, selection] : group.placed) + { + if (index < count) + { + out.selections[index] = ALTextRange(moved(selection.begin), moved(selection.end)); + } + } + for (Replacement& replacement : group.replacements) + { + const ALTextRange range = replacement.range.normalised(); + ends.emplace_back(range.end, endOf(moved(range.begin), replacement.text)); + out.replacements.push_back({ range, std::move(replacement.text) }); + } + } + return out; +} + +std::vector lineRuns(const std::vector& selections, bool touching) +{ + std::vector runs; + for (size_t i = 0; i < selections.size(); ++i) + { + const auto [first, last] = selectedLines(selections[i]); + if (!runs.empty() && first <= runs.back().last + (touching ? 1 : 0)) + { + runs.back().last = std::max(runs.back().last, last); + runs.back().selections.push_back(i); + continue; + } + LineRun run; + run.first = first; + run.last = last; + run.selections.push_back(i); + runs.push_back(std::move(run)); + } + return runs; +} + +std::vector duplicateLines(const ALTextDocument& doc, const std::vector& selections) +{ + std::vector groups; + for (const LineRun& run : lineRuns(selections, false)) + { + Group group; + group.replacements.push_back(duplicated(doc, run.first, run.last)); + // Each selection with the copy. + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, lineShifted(selections[i], run.last - run.first + 1)); + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector moveLines(const ALTextDocument& doc, const std::vector& selections, S32 direction) +{ + std::vector groups; + const std::vector runs = lineRuns(selections, true); + for (const LineRun& run : runs) + { + if ((direction < 0 && run.first == 0) || (direction > 0 && run.last + 1 >= doc.lineCount())) + { + return {}; + } + } + for (const LineRun& run : runs) + { + std::optional change = moveLinesTo(doc, run.first, run.last, direction < 0 ? run.first - 2 : run.last + 1); + if (!change) + { + continue; + } + Group group; + group.replacements = std::move(change->replacements); + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, lineShifted(selections[i], direction)); + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector deleteLines(const ALTextDocument& doc, const std::vector& selections) +{ + std::vector groups; + for (const LineRun& run : lineRuns(selections, true)) + { + S32 line = run.first; + S32 length = 0; + Group group; + group.replacements.push_back({ deleted(doc, run.first, run.last, line, length), std::string() }); + // Each caret on the line that took their place, in its column where + // that line has it. + for (const size_t i : run.selections) + { + const ALTextPos caret(line, llmin(selections[i].end.column, length)); + group.placed.emplace_back(i, ALTextRange(caret, caret)); + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector toggleComment(const ALTextDocument& doc, const std::vector& selections, const std::string& token) +{ + const std::vector runs = lineRuns(selections, false); + // Out where every line that says anything is commented, in all of them. + bool out = true; + bool any_text = false; + for (const LineRun& run : runs) + { + if (const std::optional all = commented(doc, run.first, run.last, token)) + { + any_text = true; + out = out && *all; + } + } + if (!any_text) + { + return {}; + } + std::vector groups; + for (const LineRun& run : runs) + { + Group group; + group.replacements = commentLines(doc, run.first, run.last, token, out); + if (group.replacements.empty()) + { + continue; + } + // A caret where it was in its text; a selection the lines whole. + for (const size_t i : run.selections) + { + const ALTextRange& selection = selections[i]; + if (selection.empty()) + { + const ALTextPos caret = placedThrough(group.replacements, selection.end); + group.placed.emplace_back(i, ALTextRange(caret, caret)); + } + else + { + group.placed.emplace_back(i, wholeLines(doc, selection, group.replacements)); + } + } + groups.push_back(std::move(group)); + } + return groups; +} + +std::vector joinLines(const ALTextDocument& doc, const std::vector& selections) +{ + // Each selection's lines, or a caret's line and the next, gathered + // where they share a line. + std::vector runs; + for (size_t i = 0; i < selections.size(); ++i) + { + const auto [first, selected] = selectedLines(selections[i]); + const S32 last = llmax(selected, first + 1); + if (!runs.empty() && first <= runs.back().last) + { + runs.back().last = std::max(runs.back().last, last); + runs.back().selections.push_back(i); + continue; + } + LineRun run; + run.first = first; + run.last = last; + run.selections.push_back(i); + runs.push_back(std::move(run)); + } + std::vector groups; + for (const LineRun& run : runs) + { + std::optional change = joinLines(doc, run.first, run.last, false); + if (!change) + { + continue; + } + Group group; + group.replacements = std::move(change->replacements); + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, ALTextRange(change->caret, change->caret)); + } + groups.push_back(std::move(group)); + } + return groups; +} } diff --git a/indra/llui/altextediting.h b/indra/llui/altextediting.h index 5a2800e202..88881d51b3 100644 --- a/indra/llui/altextediting.h +++ b/indra/llui/altextediting.h @@ -90,4 +90,64 @@ namespace ALTextEditing // `keep_blanks`, the break alone, as its gJ. The caret where the first // join is. Nothing where last is not past first. std::optional joinLines(const ALTextDocument& doc, S32 first, S32 last, bool keep_blanks); + + // Where text put in at a place ends: past its last line break, or along + // the place's own line. + ALTextPos endOf(const ALTextPos& at, const std::string& text); + // A place in a text moved by replacements of it, in order and none over + // another, as a mark is (ALTextDocument::Edit::placed): pushed along by + // text put in right at it, unless not `pushed`. + ALTextPos placedThrough(const std::vector& replacements, const ALTextPos& pos, bool pushed = true); + + // --- several selections at once --------------------------------------------- + + // A command done at several selections at once is worked out in groups: + // a selection on its own, or those that share the lines a command over + // whole lines does once. A group says what it replaces, over the text as + // it stands, in order and none over another; and where each selection it + // is for is after -- by its index among the selections the command was + // given, anchor to caret -- in the text as the group's own replacements + // leave it, as a Change says of its one selection. + struct Group + { + std::vector replacements; + std::vector> placed; + }; + // Groups made one change of the text: every replacement in order, and + // each selection where its group leaves it in the text as all of them + // do, the groups before it having moved it along. A group whose + // replacements land over one's before it is left out, and its selections + // with it: those, and any no group placed, are nothing here, for + // whoever makes the change to slide along with the text. + struct Combined + { + std::vector replacements; + std::vector> selections; + }; + Combined combine(std::vector groups, size_t count); + + // Selections, in the order they begin, gathered into runs over the same + // lines -- or over lines next to each other too, where `touching` -- + // each run's lines, first to last, and the selections in it. + struct LineRun + { + S32 first = 0; + S32 last = 0; + std::vector selections; + }; + std::vector lineRuns(const std::vector& selections, bool touching); + + // The commands over whole lines, at several selections in the order + // they begin: each run of them over the same lines done once, each + // selection going as the one selection goes. Moving lines takes runs on + // lines next to each other as one, as does deleting them, and moves + // nothing where any run is at the top or the bottom already; joining + // puts every caret of a run where its first join is. Comments go in, or + // out, the same way for all of them: out where every line any of them + // reaches that says anything is commented. + std::vector duplicateLines(const ALTextDocument& doc, const std::vector& selections); + std::vector moveLines(const ALTextDocument& doc, const std::vector& selections, S32 direction); + std::vector deleteLines(const ALTextDocument& doc, const std::vector& selections); + std::vector toggleComment(const ALTextDocument& doc, const std::vector& selections, const std::string& token); + std::vector joinLines(const ALTextDocument& doc, const std::vector& selections); } diff --git a/indra/llui/altextindent.cpp b/indra/llui/altextindent.cpp index fc400b467f..962a75ee45 100644 --- a/indra/llui/altextindent.cpp +++ b/indra/llui/altextindent.cpp @@ -611,32 +611,65 @@ std::optional reindentPasted(const ALTextDocument& doc, const ALTextPos& // --- whole lines ------------------------------------------------------------------ -Change indentLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, bool in, const Options& options) +namespace { - const auto [first, last] = ALTextEditing::selectedLines(ALTextRange(anchor, caret)); - Change change = shiftLines(doc, first, last, 1, in, options); - // What each line gained or lost at its start. - std::vector delta(static_cast(last - first + 1), 0); - for (const ALTextEditing::Replacement& replacement : change.replacements) + // What each line first through last gained or lost at its start, by + // the replacements of a shift of them. + std::vector shiftOf(const Change& change, S32 first, S32 last, bool in) { - const S32 line = replacement.range.begin.line; - delta[line - first] = in ? static_cast(replacement.text.size()) : -replacement.range.end.column; + std::vector delta(static_cast(last - first + 1), 0); + for (const ALTextEditing::Replacement& replacement : change.replacements) + { + delta[static_cast(replacement.range.begin.line - first)] = in ? static_cast(replacement.text.size()) : -replacement.range.end.column; + } + return delta; } - // The caret and the anchor stay where they were, moved by what their - // lines gained or lost, and never before a line's start. - const auto moved = [&](ALTextPos pos) { - if (pos.line >= first && pos.line <= last) + + // A place where it was, moved by what its line gained or lost, and + // never before the line's start. + ALTextPos movedWith(ALTextPos pos, const std::vector& delta, S32 first) + { + if (pos.line >= first && pos.line < first + static_cast(delta.size())) { - pos.column = llmax(0, pos.column + delta[pos.line - first]); + pos.column = llmax(0, pos.column + delta[static_cast(pos.line - first)]); } return pos; - }; - change.selects = true; - change.anchor = moved(anchor); - change.caret = moved(caret); + } +} + +Change indentLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, bool in, const Options& options) +{ + const auto [first, last] = ALTextEditing::selectedLines(ALTextRange(anchor, caret)); + Change change = shiftLines(doc, first, last, 1, in, options); + const std::vector delta = shiftOf(change, first, last, in); + change.selects = true; + change.anchor = movedWith(anchor, delta, first); + change.caret = movedWith(caret, delta, first); return change; } +std::vector indentLines(const ALTextDocument& doc, const std::vector& selections, bool in, const Options& options) +{ + std::vector groups; + for (const ALTextEditing::LineRun& run : ALTextEditing::lineRuns(selections, false)) + { + Change change = shiftLines(doc, run.first, run.last, 1, in, options); + if (change.replacements.empty()) + { + continue; + } + const std::vector delta = shiftOf(change, run.first, run.last, in); + ALTextEditing::Group group; + group.replacements = std::move(change.replacements); + for (const size_t i : run.selections) + { + group.placed.emplace_back(i, ALTextRange(movedWith(selections[i].begin, delta, run.first), movedWith(selections[i].end, delta, run.first))); + } + groups.push_back(std::move(group)); + } + return groups; +} + Change shiftLines(const ALTextDocument& doc, S32 first, S32 last, S32 levels, bool in, const Options& options) { levels = llmax(1, levels); diff --git a/indra/llui/altextindent.h b/indra/llui/altextindent.h index 85b7e50fdb..70d8118ce5 100644 --- a/indra/llui/altextindent.h +++ b/indra/llui/altextindent.h @@ -178,6 +178,11 @@ namespace ALTextIndent // Each line a level in, where it has anything on it, or out by what a // level is; the caret and the anchor moved with their lines' starts. Change indentLines(const ALTextDocument& doc, const ALTextPos& anchor, const ALTextPos& caret, bool in, const Options& options); + // The same at several selections in the order they begin: each run of + // them over the same lines a level in or out once, and each selection + // moved with its lines as the one is. + std::vector indentLines(const ALTextDocument& doc, const std::vector& selections, bool in, + const Options& options); // Lines first through last so many levels in, those that are not // empty -- a tab a level, or a tab's width of spaces where tabs are // soft -- or out, each level a tab or up to a tab's width of spaces: diff --git a/indra/llui/altextundo.cpp b/indra/llui/altextundo.cpp index 722f8159ea..8556d1deeb 100644 --- a/indra/llui/altextundo.cpp +++ b/indra/llui/altextundo.cpp @@ -361,7 +361,7 @@ void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& bef // the key was typed where the run left the caret, or its text // ended, with nothing selected; the rest of what the key does is // one with the first, at the same moment. - key = keyOf(Kind::Typing); + key = keyOf(mTypingErases ? Kind::Erasing : Kind::Typing); if (!mTypingNoted) { mTypingNoted = true; @@ -387,12 +387,13 @@ void ALTextUndo::record(const ALTextDocument::Edit& edit, const ALTextRange& bef forgetOverBudget(); } -void ALTextUndo::beginTyping(const ALTextRange& selection) +void ALTextUndo::beginTyping(const ALTextRange& selection, bool erasing) { if (mTypingDepth++ == 0) { - mTypingAt = selection; - mTypingNoted = false; + mTypingAt = selection; + mTypingNoted = false; + mTypingErases = erasing; } } diff --git a/indra/llui/altextundo.h b/indra/llui/altextundo.h index 3dbd1c6d08..069f1382ad 100644 --- a/indra/llui/altextundo.h +++ b/indra/llui/altextundo.h @@ -126,8 +126,10 @@ class ALTextUndo // before it where the key was typed, nothing selected, where the run // left the caret or its text, within the window; else it starts a // run. Given the selection the key was typed at. Scopes nest, as a - // key's handlers do; a group open takes precedence. - void beginTyping(const ALTextRange& selection); + // key's handlers do; a group open takes precedence. A key that erases + // -- Backspace at several carets, which is one edit of many stretches + // and so no run of its own kind -- is a run of erasing keys instead. + void beginTyping(const ALTextRange& selection, bool erasing = false); void endTyping(); // Everything recorded until endGroup() is one step. @@ -266,6 +268,7 @@ class ALTextUndo // for has recorded an edit yet: the rest of what it does joins that. S32 mTypingDepth = 0; bool mTypingNoted = false; + bool mTypingErases = false; // The selection the key was typed at, anchor to caret. ALTextRange mTypingAt; // What the steps back weigh together, kept as they change rather than diff --git a/indra/llui/altextview.cpp b/indra/llui/altextview.cpp index 38a217a1c4..855537e8bd 100644 --- a/indra/llui/altextview.cpp +++ b/indra/llui/altextview.cpp @@ -1871,20 +1871,19 @@ void ALTextView::onDocumentEdit(const ALTextDocument::Edit& edit) } } -void ALTextView::moveVertically(S32 rows, bool extend) +ALTextPos ALTextView::rowsFrom(const ALTextPos& from, S32 rows, F32& desired_x) { - const S32 row_h = mLayout.rowHeight(); - if (row_h <= 0) + if (mLayout.rowHeight() <= 0) { - return; + return from; } S32 row; - const F32 x = mLayout.xOf(mCaret.line, mCaret.column, &row); - if (mDesiredX < 0.f) + const F32 x = mLayout.xOf(from.line, from.column, &row); + if (desired_x < 0.f) { - mDesiredX = x; + desired_x = x; } - S32 line = mCaret.line; + S32 line = from.line; for (; rows < 0; ++rows) { if (row > 0) @@ -1898,10 +1897,8 @@ void ALTextView::moveVertically(S32 rows, bool extend) } else { - placeCaret(mDocument.start(), extend); - mDesiredX = -1.f; - scrollToCaret(); - return; + desired_x = -1.f; + return mDocument.start(); } } for (; rows > 0; --rows) @@ -1917,14 +1914,129 @@ void ALTextView::moveVertically(S32 rows, bool extend) } else { - placeCaret(mDocument.end(), extend); - mDesiredX = -1.f; - scrollToCaret(); - return; + desired_x = -1.f; + return mDocument.end(); } } - placeCaret(ALTextPos(line, mLayout.columnAt(line, row, mDesiredX, true)), extend); - scrollToCaret(); + return ALTextPos(line, mLayout.columnAt(line, row, desired_x, true)); +} + +namespace +{ + bool isMotion(ALEditorCommand command) + { + typedef ALEditorCommand C; + return (command >= C::MoveLeft && command <= C::MovePageDown) || (command >= C::SelectLeft && command <= C::SelectPageDown) || + (command >= C::MoveSubwordLeft && command <= C::SelectSubwordRight); + } + + bool isVertical(ALEditorCommand command) + { + typedef ALEditorCommand C; + switch (command) + { + case C::MoveUp: + case C::MoveDown: + case C::MovePageUp: + case C::MovePageDown: + case C::SelectUp: + case C::SelectDown: + case C::SelectPageUp: + case C::SelectPageDown: + return true; + default: + return false; + } + } +} + +ALTextRange ALTextView::moved(ALEditorCommand command, const ALTextRange& selection, F32& desired_x) +{ + typedef ALEditorCommand C; + const bool extend = (command >= C::SelectLeft && command <= C::SelectPageDown) || command == C::SelectSubwordLeft || + command == C::SelectSubwordRight; + const ALTextPos& caret = selection.end; + const ALTextRange range = selection.normalised(); + // An arrow with a selection and no shift collapses it to that end. + const bool collapse = !extend && !range.empty(); + ALTextPos to = caret; + switch (command) + { + case C::MoveLeft: + case C::SelectLeft: + to = collapse ? range.begin : mDocument.prevCluster(caret); + break; + case C::MoveRight: + case C::SelectRight: + to = collapse ? range.end : mDocument.nextCluster(caret); + break; + case C::MoveUp: + case C::SelectUp: + to = rowsFrom(caret, -1, desired_x); + break; + case C::MoveDown: + case C::SelectDown: + to = rowsFrom(caret, 1, desired_x); + break; + case C::MovePageUp: + case C::SelectPageUp: + to = rowsFrom(caret, -rowsPerPage(), desired_x); + break; + case C::MovePageDown: + case C::SelectPageDown: + to = rowsFrom(caret, rowsPerPage(), desired_x); + break; + case C::MoveWordLeft: + case C::SelectWordLeft: + to = wordStep(caret, false, false); + break; + case C::MoveWordRight: + case C::SelectWordRight: + to = wordStep(caret, true, false); + break; + case C::MoveSubwordLeft: + case C::SelectSubwordLeft: + to = wordStep(caret, false, true); + break; + case C::MoveSubwordRight: + case C::SelectSubwordRight: + to = wordStep(caret, true, true); + break; + case C::MoveLineStart: + case C::SelectLineStart: + { + // To the first thing on the line, or to the line's start from + // there. + const std::string& line = mDocument.line(caret.line); + S32 indent = 0; + while (indent < static_cast(line.size()) && (line[indent] == ' ' || line[indent] == '\t')) + { + ++indent; + } + to = ALTextPos(caret.line, caret.column == indent ? 0 : indent); + break; + } + case C::MoveLineEnd: + case C::SelectLineEnd: + to = mDocument.lineEnd(caret.line); + break; + case C::MoveDocStart: + case C::SelectDocStart: + to = mDocument.start(); + break; + case C::MoveDocEnd: + case C::SelectDocEnd: + to = mDocument.end(); + break; + default: + return selection; + } + if (!isVertical(command)) + { + desired_x = -1.f; + } + to = snapped(mDocument.clamp(to), caret); + return ALTextRange(extend ? selection.begin : to, to); } // --- editing ------------------------------------------------------------------- @@ -2251,6 +2363,505 @@ std::string ALTextView::tabText(const ALTextPos& at) const return ALTextIndent::tabText(mDocument, at, editingOptions()); } +bool ALTextView::commentBefore(const ALTextPos& at) +{ + if (!mHighlighter.grammar() || at.column <= 0) + { + return false; + } + for (const ALSyntaxToken& token : mHighlighter.tokens(at.line)) + { + if (token.begin < at.column && at.column <= token.end) + { + return token.kind == ALSyntaxKind::Comment || token.kind == ALSyntaxKind::DocComment; + } + } + return false; +} + +// --- at every selection ------------------------------------------------------------ + +std::vector ALTextView::selectionsInOrder(size_t* main) const +{ + const ALTextRange mine = selection(); + const ALTextPos begin = mine.normalised().begin; + const std::vector& others = mCarets.selections(); + // Where the main one goes among the others: none of them begins where + // it does, or they would be one. + const auto at = std::lower_bound(others.begin(), others.end(), begin, + [](const ALTextRange& other, const ALTextPos& p) { return other.normalised().begin < p; }); + std::vector all; + all.reserve(others.size() + 1); + all.insert(all.end(), others.begin(), at); + if (main) + { + *main = all.size(); + } + all.push_back(mine); + all.insert(all.end(), at, others.end()); + return all; +} + +bool ALTextView::anySelected() const +{ + return hasSelection() || std::any_of(mCarets.begin(), mCarets.end(), [](const ALTextRange& other) { return !other.empty(); }); +} + +void ALTextView::placeSelections(const std::vector& selections, size_t main) +{ + std::vector others; + others.reserve(selections.size()); + for (size_t i = 0; i < selections.size(); ++i) + { + if (i != main) + { + others.push_back(selections[i]); + } + } + mCarets.assign(clamped(std::move(others))); + placeSelection(selections[main].begin, selections[main].end); +} + +bool ALTextView::applyGroups(const std::vector& selections, size_t main, std::vector groups) +{ + ALTextEditing::Combined combined = ALTextEditing::combine(std::move(groups), selections.size()); + std::vector> edits; + edits.reserve(combined.replacements.size()); + for (ALTextEditing::Replacement& one : combined.replacements) + { + edits.emplace_back(one.range, std::move(one.text)); + } + // Made whole or not at all: a change of several stretches that the text + // has no room for is not made. + ALTextDocument::Edit done; + if (!edits.empty()) + { + if (!fits(edits)) + { + return false; + } + done = editMany(std::move(edits), combined.selections[main] ? combined.selections[main]->end : mCaret); + } + std::vector placed(selections.size()); + for (size_t i = 0; i < selections.size(); ++i) + { + if (combined.selections[i]) + { + placed[i] = *combined.selections[i]; + } + else + { + placed[i] = done.nothing() ? selections[i] : ALTextCarets::slid(selections[i], done); + } + } + if (!edits.empty() && done.nothing()) + { + // Nothing changed: each where it was. + return false; + } + placeSelections(placed, main); + if (done.nothing()) + { + mDesiredX = -1.f; + scrollToCaret(); + return false; + } + afterEdit(); + return true; +} + +bool ALTextView::editEach(const each_change_t& change) +{ + // The steps are of the text without a composition in it, and so are + // the selections each is worked out at. + if (hasPreedit()) + { + resetPreedit(); + } + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + std::vector groups; + for (size_t i = 0; i < all.size(); ++i) + { + std::optional one = change(i, all[i]); + if (!one) + { + continue; + } + ALTextEditing::Group group; + group.replacements = std::move(one->replacements); + group.placed.emplace_back(i, one->selects ? ALTextRange(one->anchor, one->caret) : ALTextRange(one->caret, one->caret)); + groups.push_back(std::move(group)); + } + return applyGroups(all, main, std::move(groups)); +} + +bool ALTextView::editGroups(const groups_t& groups) +{ + if (hasPreedit()) + { + resetPreedit(); + } + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + mUndo.beginGroup(); + const bool done = applyGroups(all, main, groups(all)); + mUndo.endGroup(); + return done; +} + +std::optional ALTextView::erasedBy(ALEditorCommand command, const ALTextRange& selection) const +{ + typedef ALEditorCommand C; + if (!selection.empty()) + { + return selection.normalised(); + } + const ALTextPos& at = selection.end; + switch (command) + { + case C::DeleteLeft: + if (at != mDocument.start()) + { + // In a line's indentation, a level's spaces at once. + const std::optional level = ALTextIndent::backspaceFrom(mDocument, at, editingOptions()); + return ALTextRange(level ? *level : mDocument.prevCluster(at), at); + } + break; + case C::DeleteRight: + if (at != mDocument.end()) + { + return ALTextRange(at, mDocument.nextCluster(at)); + } + break; + case C::DeleteWordLeft: + return ALTextRange(wordStep(at, false, false), at); + case C::DeleteWordRight: + return ALTextRange(at, wordStep(at, true, false)); + case C::DeleteToLineStart: + // Back to the line's start, or the break before it from there. + if (at.column > 0) + { + return ALTextRange(ALTextPos(at.line, 0), at); + } + if (at != mDocument.start()) + { + return ALTextRange(mDocument.prevCluster(at), at); + } + break; + case C::DeleteToLineEnd: + // On to the line's end, or the break after it from there. + if (at != mDocument.lineEnd(at.line)) + { + return ALTextRange(at, mDocument.lineEnd(at.line)); + } + if (at != mDocument.end()) + { + return ALTextRange(at, mDocument.nextCluster(at)); + } + break; + default: + break; + } + return std::nullopt; +} + +void ALTextView::typeAtEach(std::string_view text, llwchar typed) +{ + const std::string put(text); + editEach([&put](size_t, const ALTextRange& selection) { + const ALTextRange over = selection.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, put }); + one.caret = ALTextEditing::endOf(over.begin, put); + return std::optional(std::move(one)); + }); + if (!typed) + { + return; + } + // What the character finishes brought out at each caret; a word so + // brought out is not kept to be put back, as one caret's is. + mAutoOutdent = ALTextIndent::AutoOutdent(); + const ALSyntaxGrammar* grammar = mHighlighter.grammar().get(); + editEach([&](size_t, const ALTextRange& selection) -> std::optional { + if (!selection.empty()) + { + return std::nullopt; + } + const ALTextIndent::Outdent outdent = ALTextIndent::outdentAsTyped(mDocument, selection.begin, selection.end, typed, grammar, openerOf(), + ALTextIndent::AutoOutdent(), editingOptions()); + if (!outdent.replacement) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back(*outdent.replacement); + one.caret = ALTextEditing::placedThrough(one.replacements, selection.end); + return one; + }); +} + +std::optional ALTextView::performAtEach(ALEditorCommand command) +{ + typedef ALEditorCommand C; + if (isMotion(command)) + { + return moveEach(command); + } + switch (command) + { + case C::DeleteLeft: + case C::DeleteRight: + case C::DeleteWordLeft: + case C::DeleteWordRight: + case C::DeleteToLineStart: + case C::DeleteToLineEnd: + return deleteEach(command); + case C::NewLine: + newLineEach(); + return true; + case C::Indent: + // A selection, however small, indents its lines; a caret puts a + // tab in. + editGroups([this](const std::vector& all) { + std::vector selected; + std::vector at; + for (size_t i = 0; i < all.size(); ++i) + { + if (!all[i].empty()) + { + selected.push_back(all[i]); + at.push_back(i); + } + } + std::vector groups = ALTextIndent::indentLines(mDocument, selected, true, editingOptions()); + for (ALTextEditing::Group& group : groups) + { + for (auto& placed : group.placed) + { + placed.first = at[placed.first]; + } + } + for (size_t i = 0; i < all.size(); ++i) + { + if (all[i].empty()) + { + const std::string tab = tabText(all[i].end); + const ALTextPos end = ALTextEditing::endOf(all[i].end, tab); + ALTextEditing::Group group; + group.replacements.push_back({ all[i], tab }); + group.placed.emplace_back(i, ALTextRange(end, end)); + groups.push_back(std::move(group)); + } + } + return groups; + }); + return true; + case C::Unindent: + editGroups([this](const std::vector& all) { return ALTextIndent::indentLines(mDocument, all, false, editingOptions()); }); + return true; + case C::DuplicateLine: + editGroups([this](const std::vector& all) { return ALTextEditing::duplicateLines(mDocument, all); }); + return true; + case C::MoveLineUp: + case C::MoveLineDown: + { + const S32 direction = command == C::MoveLineUp ? -1 : 1; + editGroups([this, direction](const std::vector& all) { return ALTextEditing::moveLines(mDocument, all, direction); }); + return true; + } + case C::DeleteLine: + editGroups([this](const std::vector& all) { return ALTextEditing::deleteLines(mDocument, all); }); + return true; + case C::JoinLines: + return editGroups([this](const std::vector& all) { return ALTextEditing::joinLines(mDocument, all); }); + case C::InsertLineAbove: + // A line above each line a caret is on, as far in as that line, + // however many carets are on it. + editGroups([this](const std::vector& all) { + std::vector> lines; + for (size_t i = 0; i < all.size(); ++i) + { + lines.emplace_back(all[i].end.line, i); + } + std::sort(lines.begin(), lines.end()); + std::vector groups; + S32 indent = 0; + for (size_t k = 0; k < lines.size(); ++k) + { + const S32 line = lines[k].first; + if (k == 0 || line != lines[k - 1].first) + { + const std::string blanks = ALTextIndent::leadingBlanks(mDocument, line); + const ALTextPos start = mDocument.lineStart(line); + indent = static_cast(blanks.size()); + groups.emplace_back(); + groups.back().replacements.push_back({ ALTextRange(start, start), blanks + "\n" }); + } + const ALTextPos caret(line, indent); + groups.back().placed.emplace_back(lines[k].second, ALTextRange(caret, caret)); + } + return groups; + }); + scrollToCaret(); + return true; + case C::InsertLineBelow: + { + // As Return at each line's end would make it. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + for (ALTextRange& one : all) + { + const ALTextPos end = mDocument.lineEnd(one.end.line); + one = ALTextRange(end, end); + } + placeSelections(all, main); + newLineEach(); + scrollToCaret(); + return true; + } + case C::SelectLine: + { + // Each selection's lines whole, with their breaks. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + for (ALTextRange& one : all) + { + const ALTextRange range = one.normalised(); + const S32 past = range.end.line + 1; + one = ALTextRange(mDocument.lineStart(range.begin.line), + past < mDocument.lineCount() ? mDocument.lineStart(past) : mDocument.lineEnd(mDocument.lineCount() - 1)); + } + placeSelections(all, main); + mDesiredX = -1.f; + scrollToCaret(); + return true; + } + // Done as they always are: the clipboard's and the comment's know of + // the others; select all, undo and redo put them as they put the + // main one; folding and the find bar shown leave them be. + case C::None: + case C::COUNT: + case C::ToggleComment: + case C::Cut: + case C::Copy: + case C::Paste: + case C::Delete: + case C::SelectAll: + case C::Undo: + case C::Redo: + case C::Fold: + case C::Unfold: + case C::FoldAll: + case C::UnfoldAll: + case C::Find: + case C::Replace: + return std::nullopt; + default: + // At the main one alone: the others let go. + singleSelection(); + return std::nullopt; + } +} + +bool ALTextView::moveEach(ALEditorCommand command) +{ + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + // The x each caret keeps between rows, where the last motion left them + // and they have not moved since; else each from where it is. + const bool vertical = isVertical(command); + std::vector xs = vertical && mEachDesired.selections == all ? mEachDesired.xs : std::vector(all.size(), -1.f); + std::vector placed(all.size()); + for (size_t i = 0; i < all.size(); ++i) + { + placed[i] = moved(command, all[i], xs[i]); + } + placeSelections(placed, main); + mDesiredX = xs[main]; + mEachDesired = EachDesired(); + if (vertical) + { + std::vector now = selectionsInOrder(); + // Kept only while each is the one it was: none merged. + if (now.size() == all.size()) + { + mEachDesired.selections = std::move(now); + mEachDesired.xs = std::move(xs); + } + } + scrollToCaret(); + return true; +} + +bool ALTextView::deleteEach(ALEditorCommand command) +{ + // A key that erases, as one: a run of them one step to undo. + mUndo.beginTyping(selection(), true); + editEach([this, command](size_t, const ALTextRange& selection) -> std::optional { + const std::optional range = erasedBy(command, selection); + if (!range || range->empty()) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back({ range->normalised(), std::string() }); + one.caret = range->normalised().begin; + return one; + }); + mUndo.endTyping(); + return true; +} + +bool ALTextView::deleteSelectedEach() +{ + mUndo.beginGroup(); + const bool done = editEach([](size_t, const ALTextRange& selection) -> std::optional { + if (selection.empty()) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back({ selection.normalised(), std::string() }); + one.caret = selection.normalised().begin; + return one; + }); + mUndo.endGroup(); + return done; +} + +void ALTextView::newLineEach() +{ + const ALSyntaxGrammar* grammar = mHighlighter.grammar().get(); + // A key typed, whatever it takes: one with the typing around it. + mUndo.beginTyping(selection()); + // First what the Return finishes, brought out at each caret. + editEach([&](size_t, const ALTextRange& selection) -> std::optional { + const std::optional closing = + ALTextIndent::closingBeforeReturn(mDocument, selection.begin, selection.end, grammar, openerOf(), editingOptions()); + if (!closing) + { + return std::nullopt; + } + ALTextEditing::Change one; + one.replacements.push_back(*closing); + one.caret = ALTextEditing::placedThrough(one.replacements, selection.end); + return one; + }); + // Then each line split there. + editEach([&](size_t, const ALTextRange& selection) { + const bool in_comment = selection.empty() && commentBefore(selection.end); + const ALTextIndent::Split split = ALTextIndent::splitLine(mDocument, selection, grammar, editingOptions(), in_comment); + const ALTextRange over = split.range.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, split.text }); + one.caret = split.caret ? *split.caret : ALTextEditing::endOf(over.begin, split.text); + return std::optional(std::move(one)); + }); + mUndo.endTyping(); +} + void ALTextView::newLine() { const ALSyntaxGrammar* grammar = mHighlighter.grammar().get(); @@ -2263,19 +2874,8 @@ void ALTextView::newLine() } // In a comment, as the token before the caret says: one that goes on // begins the new line as its lines do. - bool in_comment = false; - if (grammar && mCaret.column > 0 && !hasSelection()) - { - for (const ALSyntaxToken& token : mHighlighter.tokens(mCaret.line)) - { - if (token.begin < mCaret.column && mCaret.column <= token.end) - { - in_comment = token.kind == ALSyntaxKind::Comment || token.kind == ALSyntaxKind::DocComment; - break; - } - } - } - const ALTextIndent::Split split = ALTextIndent::splitLine(mDocument, selection(), grammar, editingOptions(), in_comment); + const bool in_comment = !hasSelection() && commentBefore(mCaret); + const ALTextIndent::Split split = ALTextIndent::splitLine(mDocument, selection(), grammar, editingOptions(), in_comment); setSelection(split.range); insertText(split.text); if (split.caret) @@ -2303,6 +2903,13 @@ bool ALTextView::toggleComment() { return false; } + if (hasOtherSelections()) + { + // In or out the same way at every selection. + const std::string token = mHighlighter.grammar()->lineComment(); + editGroups([this, &token](const std::vector& all) { return ALTextEditing::toggleComment(mDocument, all, token); }); + return true; + } if (const std::optional change = ALTextEditing::toggleComment(mDocument, mAnchor, mCaret, mHighlighter.grammar()->lineComment())) { apply(*change); @@ -2313,92 +2920,57 @@ bool ALTextView::toggleComment() bool ALTextView::perform(ALEditorCommand command) { typedef ALEditorCommand C; - const bool extend = command >= C::SelectLeft && command <= C::SelectPageDown; - auto move = [&](const ALTextPos& to) { - placeCaret(to, extend); - mDesiredX = -1.f; - scrollToCaret(); - return true; - }; - // An arrow with a selection and no shift collapses it to that end. - auto collapse = [&](bool to_begin) { - if (!hasSelection() || extend) - { - return false; - } - const ALTextRange range = selection().normalised(); - return move(to_begin ? range.begin : range.end); - }; if (mReadOnly && editsText(command)) { return false; } + // At every selection, where there are several. + if (hasOtherSelections()) + { + if (const std::optional done = performAtEach(command)) + { + return *done; + } + } switch (command) { case C::None: case C::COUNT: return false; case C::MoveLeft: - case C::SelectLeft: - return collapse(true) || move(mDocument.prevCluster(mCaret)); case C::MoveRight: - case C::SelectRight: - return collapse(false) || move(mDocument.nextCluster(mCaret)); case C::MoveUp: - case C::SelectUp: - moveVertically(-1, extend); - return true; case C::MoveDown: - case C::SelectDown: - moveVertically(1, extend); - return true; case C::MoveWordLeft: - case C::SelectWordLeft: - return move(wordStep(mCaret, false, false)); case C::MoveWordRight: - case C::SelectWordRight: - return move(wordStep(mCaret, true, false)); - case C::MoveSubwordLeft: - case C::SelectSubwordLeft: - case C::MoveSubwordRight: - case C::SelectSubwordRight: - { - const bool forward = command == C::MoveSubwordRight || command == C::SelectSubwordRight; - placeCaret(wordStep(mCaret, forward, true), command == C::SelectSubwordLeft || command == C::SelectSubwordRight); - mDesiredX = -1.f; - scrollToCaret(); - return true; - } case C::MoveLineStart: - case C::SelectLineStart: - { - // To the first thing on the line, or to the line's start from - // there. - const std::string& line = mDocument.line(mCaret.line); - S32 indent = 0; - while (indent < static_cast(line.size()) && (line[indent] == ' ' || line[indent] == '\t')) - { - ++indent; - } - return move(ALTextPos(mCaret.line, mCaret.column == indent ? 0 : indent)); - } case C::MoveLineEnd: - case C::SelectLineEnd: - return move(mDocument.lineEnd(mCaret.line)); case C::MoveDocStart: - case C::SelectDocStart: - return move(mDocument.start()); case C::MoveDocEnd: - case C::SelectDocEnd: - return move(mDocument.end()); case C::MovePageUp: - case C::SelectPageUp: - moveVertically(-rowsPerPage(), extend); - return true; case C::MovePageDown: + case C::SelectLeft: + case C::SelectRight: + case C::SelectUp: + case C::SelectDown: + case C::SelectWordLeft: + case C::SelectWordRight: + case C::SelectLineStart: + case C::SelectLineEnd: + case C::SelectDocStart: + case C::SelectDocEnd: + case C::SelectPageUp: case C::SelectPageDown: - moveVertically(rowsPerPage(), extend); + case C::MoveSubwordLeft: + case C::MoveSubwordRight: + case C::SelectSubwordLeft: + case C::SelectSubwordRight: + { + const ALTextRange to = moved(command, selection(), mDesiredX); + placeSelection(to.begin, to.end); + scrollToCaret(); return true; + } case C::SelectAll: selectAll(); return true; @@ -2768,6 +3340,20 @@ void ALTextView::cut() return; } copy(); + if (hasOtherSelections()) + { + // What each selected; or where none selected anything, each caret's + // line whole. + if (anySelected()) + { + deleteSelectedEach(); + } + else + { + editGroups([this](const std::vector& all) { return ALTextEditing::deleteLines(mDocument, all); }); + } + return; + } if (!hasSelection()) { // The whole line, the caret on the one that takes its place. @@ -2779,6 +3365,32 @@ void ALTextView::cut() void ALTextView::copy() { + if (hasOtherSelections()) + { + // Each selection's text, one to a line in the order they begin: or + // a caret's whole line where lines are clipped, else nothing of it. + std::string text; + bool any = false; + for (const ALTextRange& one : selectionsInOrder()) + { + if (one.empty() && !mClipsLines) + { + continue; + } + if (any) + { + text += '\n'; + } + text += one.empty() ? mDocument.line(one.end.line) : mDocument.text(one); + any = true; + } + if (any) + { + sClippedLine.clear(); + LLClipboard::instance().copyToClipboard(text, 0, static_cast(text.size())); + } + return; + } if (!hasSelection()) { if (!mClipsLines) @@ -2811,6 +3423,43 @@ void ALTextView::paste() { return; } + if (hasOtherSelections()) + { + // Its lines, as the text will have them. + LLStringUtil::replaceString(text, "\r\n", "\n"); + LLStringUtil::replaceChar(text, '\r', '\n'); + std::vector lines; + for (size_t from = 0;;) + { + const size_t end = text.find('\n', from); + lines.push_back(text.substr(from, end == std::string::npos ? std::string::npos : end - from)); + if (end == std::string::npos) + { + break; + } + from = end + 1; + } + // One line to each selection where there is a line for each -- a + // break after the last not counted -- and the whole to each + // otherwise. + const size_t count = mCarets.size() + 1; + if (lines.size() == count + 1 && lines.back().empty()) + { + lines.pop_back(); + } + const bool one_each = lines.size() == count; + mUndo.beginGroup(); + editEach([&](size_t index, const ALTextRange& selection) { + const std::string& put = one_each ? lines[index] : text; + const ALTextRange over = selection.normalised(); + ALTextEditing::Change one; + one.replacements.push_back({ over, put }); + one.caret = ALTextEditing::endOf(over.begin, put); + return std::optional(std::move(one)); + }); + mUndo.endGroup(); + return; + } // A line copied whole goes in as a line, above the caret's, the caret // where it was in its own. if (mClipsLines && !hasSelection() && !sClippedLine.empty() && text == sClippedLine) @@ -2846,10 +3495,16 @@ void ALTextView::paste() void ALTextView::doDelete() { - if (canDoDelete()) + if (!canDoDelete()) { - deleteRange(selection()); + return; + } + if (hasOtherSelections()) + { + deleteSelectedEach(); + return; } + deleteRange(selection()); } void ALTextView::selectAll() @@ -4499,8 +5154,15 @@ bool ALTextView::handleUnicodeCharHere(llwchar uni_char) return false; } mUndo.beginTyping(selection()); - insertText(utf8str_from_cp(uni_char)); - outdentAsTyped(uni_char); + if (hasOtherSelections()) + { + typeAtEach(utf8str_from_cp(uni_char), uni_char); + } + else + { + insertText(utf8str_from_cp(uni_char)); + outdentAsTyped(uni_char); + } mUndo.endTyping(); if (LLWindow* window = getWindow()) { diff --git a/indra/llui/altextview.h b/indra/llui/altextview.h index 3e0b9c7e6b..ff0a66b7c7 100644 --- a/indra/llui/altextview.h +++ b/indra/llui/altextview.h @@ -352,7 +352,11 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec // The selections besides the main one, each anchor to caret, in the // order they begin (ALTextCarets). Drawn as the main one is, slid along // by every edit, and kept by each step to undo; merged with each other - // and with the main one where they meet. + // and with the main one where they meet. Keys act at every one of them + // -- typing, deleting, Return and Tab, the commands over lines, the + // clipboard, and the caret's motions -- each as at the main one, as one + // edit and one step to undo; a command that can act only at one acts at + // the main one and lets the others go. const std::vector& otherSelections() const { return mCarets.selections(); } bool hasOtherSelections() const { return !mCarets.empty(); } // One more beside the main one, which stays main. @@ -712,13 +716,13 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec void redo() override; bool canRedo() const override { return !mReadOnly && mUndo.canRedo(); } void cut() override; - bool canCut() const override { return !mReadOnly && (hasSelection() || mClipsLines); } + bool canCut() const override { return !mReadOnly && (anySelected() || mClipsLines); } void copy() override; - bool canCopy() const override { return hasSelection() || mClipsLines; } + bool canCopy() const override { return anySelected() || mClipsLines; } void paste() override; bool canPaste() const override; void doDelete() override; - bool canDoDelete() const override { return !mReadOnly && hasSelection(); } + bool canDoDelete() const override { return !mReadOnly && anySelected(); } void selectAll() override; bool canSelectAll() const override { return !mDocument.empty(); } void deselect() override; @@ -913,6 +917,39 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec // as though it were not. ALTextRange withoutComposition(const ALTextRange& range) const; + // --- at every selection ------------------------------------------------------- + + // Every selection, the main one among them, in the order they begin; + // and where among them the main one is. + std::vector selectionsInOrder(size_t* main = nullptr) const; + // Whether any selection, the main one or another, has something in it. + bool anySelected() const; + // Selections put at once, the one at `main` the main one: where a + // command at every selection leaves them. + void placeSelections(const std::vector& selections, size_t main); + // A command at every selection (selectionsInOrder), in the groups it was + // worked out in over the text as it stands (ALTextEditing::combine): + // made as one edit -- one notification, and one step to undo with the + // journal's scope around it -- and each selection put where its group + // leaves it, any no group places slid along with the text. False where + // the text did not change: nothing would, or it has no room. + bool applyGroups(const std::vector& selections, size_t main, std::vector groups); + // The same, worked out at each selection on its own: what `change` + // makes of the one at an index, or nothing for one it leaves as it is. + typedef std::function(size_t index, const ALTextRange& selection)> each_change_t; + bool editEach(const each_change_t& change); + // Groups worked out over every selection at once, made as one step to + // undo of its own: the commands over whole lines. + typedef std::function(const std::vector& selections)> groups_t; + bool editGroups(const groups_t& groups); + // What a delete command takes at a selection: the selection, or from a + // caret what the command reaches; nothing where it reaches nothing. + std::optional erasedBy(ALEditorCommand command, const ALTextRange& selection) const; + // Typed at every selection: the text in place of each, and then, where + // it was a character that finishes what closes a block, that line + // brought out at each (outdentAsTyped) -- one key typed. + void typeAtEach(std::string_view text, llwchar typed); + // --- LLPreeditor --------------------------------------------------------- void resetPreedit() override; @@ -926,7 +963,35 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec const std::string& getPreeditStringUtf8() const override; private: - void moveVertically(S32 rows, bool extend); + // A motion of the caret at a selection: where the caret goes, the + // anchor kept where the motion extends, and an arrow with no shift + // collapsing what is selected to that end; the x a caret keeps between + // rows in `desired_x`, which every other motion lets go of. + ALTextRange moved(ALEditorCommand command, const ALTextRange& selection, F32& desired_x); + // Where a caret goes so many rows down, or up below zero, keeping to an + // x -- taken from where it is, where that is negative -- or to the + // text's start or end past its first or last row, letting the x go. + ALTextPos rowsFrom(const ALTextPos& from, S32 rows, F32& desired_x); + // Whether the token a caret is at the end of is a comment's. + bool commentBefore(const ALTextPos& at); + // A command where there are several selections: done at every one and + // whether it was, as perform() says; or nothing for one perform() does + // as it would, having let the others go where it acts at the main one + // alone. + std::optional performAtEach(ALEditorCommand command); + bool moveEach(ALEditorCommand command); + bool deleteEach(ALEditorCommand command); + void newLineEach(); + // Each selection with something in it gone, as one step to undo. + bool deleteSelectedEach(); + // The x each caret keeps between rows, as a vertical motion of several + // left them, for as long as they are where it left them. + struct EachDesired + { + std::vector selections; + std::vector xs; + }; + EachDesired mEachDesired; // Return: the line split with the new one indented as the grammar // says, and a closing word before the caret brought out first. void newLine(); diff --git a/indra/llui/tests/alcodeeditor_test.cpp b/indra/llui/tests/alcodeeditor_test.cpp index 605b663cbc..a7e401060d 100644 --- a/indra/llui/tests/alcodeeditor_test.cpp +++ b/indra/llui/tests/alcodeeditor_test.cpp @@ -2615,4 +2615,33 @@ namespace tut key(KEY_ESCAPE); ensure("and the box goes with the list", !fixes->sideShown() && !fixes->getVisible()); } + + template<> template<> + void alcodeeditor_object::test<73>() + { + set_test_name("pairs at every caret: closed, typed over and taken away together at each, a selection wrapped; a character typed as ever where it pairs nothing"); + ALCodeEditor& e = make("a\nb\nc"); + e.setAutoClose(true); + e.setSelections(ALTextRange(ALTextPos(0, 1), ALTextPos(0, 1)), { ALTextRange(ALTextPos(1, 1), ALTextPos(1, 1)), ALTextRange(ALTextPos(2, 1), ALTextPos(2, 1)) }); + type("("); + ensure_equals("closed at each", e.text(), std::string("a()\nb()\nc()")); + ensure("the main caret between", e.caret() == ALTextPos(0, 2)); + type("x)"); + ensure_equals("each closer typed over", e.text(), std::string("a(x)\nb(x)\nc(x)")); + ensure("past it", e.caret() == ALTextPos(0, 4)); + type("["); + key(KEY_BACKSPACE); + ensure_equals("one Backspace takes each pair", e.text(), std::string("a(x)\nb(x)\nc(x)")); + e.undo(); + ensure_equals("the Backspace undone", e.text(), std::string("a(x)[]\nb(x)[]\nc(x)[]")); + + e.setText("one two"); + e.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 3)), { ALTextRange(ALTextPos(0, 4), ALTextPos(0, 7)) }); + type("\""); + ensure_equals("each selection wrapped", e.text(), std::string("\"one\" \"two\"")); + e.setText("x\n// y"); + e.setSelections(ALTextRange(ALTextPos(0, 1), ALTextPos(0, 1)), { ALTextRange(ALTextPos(1, 4), ALTextPos(1, 4)) }); + type("("); + ensure_equals("closed where it is code, typed as ever in the comment", e.text(), std::string("x()\n// y(")); + } } diff --git a/indra/llui/tests/altextdocument_test.cpp b/indra/llui/tests/altextdocument_test.cpp index 5d5c8392bc..c87ba99854 100644 --- a/indra/llui/tests/altextdocument_test.cpp +++ b/indra/llui/tests/altextdocument_test.cpp @@ -462,4 +462,26 @@ namespace tut ensure("off a line's end to the next", d.nextCluster(ALTextPos(0, static_cast(lines[0].size()))) == ALTextPos(1, 0)); ensure("back off its start to the one before", d.prevCluster(ALTextPos(1, 0)) == ALTextPos(0, static_cast(lines[0].size()))); } + + template<> template<> + void altextdocument_object::test<20>() + { + set_test_name("a place moved by an edit, pushed by text put in at it or not; and the stretch a place stood in, a batch's own, not the text between"); + ALTextDocument doc("abcdef"); + const ALTextDocument::Edit typed = doc.insert(ALTextPos(0, 2), "XY"); + ensure("pushed", typed.placed(ALTextPos(0, 2)) == ALTextPos(0, 4)); + ensure("not pushed", typed.placed(ALTextPos(0, 2), false) == ALTextPos(0, 2)); + ensure("after it moved either way", typed.placed(ALTextPos(0, 3), false) == ALTextPos(0, 5)); + ensure("nothing stood in what was put in", !typed.replacedAround(ALTextPos(0, 2))); + + // "abXYcdef": "XY" and "ef" replaced at once. + const ALTextDocument::Edit batch = + doc.replaceMany({ { ALTextRange(ALTextPos(0, 2), ALTextPos(0, 4)), "-" }, { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 8)), "" } }); + ensure_equals("made", doc.text(), std::string("ab-cd")); + ensure("in the first", batch.replacedAround(ALTextPos(0, 3)) == ALTextRange(ALTextPos(0, 2), ALTextPos(0, 4))); + ensure("at the second's start", batch.replacedAround(ALTextPos(0, 6)) == ALTextRange(ALTextPos(0, 6), ALTextPos(0, 8))); + ensure("not between them", !batch.replacedAround(ALTextPos(0, 4)) && !batch.replacedAround(ALTextPos(0, 5))); + ensure("nor at an end", !batch.replacedAround(ALTextPos(0, 8))); + ensure("between them, moved by the first", batch.placed(ALTextPos(0, 5)) == ALTextPos(0, 4)); + } } diff --git a/indra/llui/tests/altextediting_test.cpp b/indra/llui/tests/altextediting_test.cpp index 380d7c99bc..dc3c523cbe 100644 --- a/indra/llui/tests/altextediting_test.cpp +++ b/indra/llui/tests/altextediting_test.cpp @@ -28,7 +28,9 @@ #include "../test/lltut.h" +#include #include +#include namespace tut { @@ -48,6 +50,31 @@ namespace tut } }; + // Several selections' groups made as one: the text after, and each + // selection where it is, "-" for one left to slide. + static std::string combined(const std::string& text, std::vector groups, size_t count, std::string& placed) + { + Combined all = combine(std::move(groups), count); + ALTextDocument doc(text); + std::vector> edits; + for (const Replacement& one : all.replacements) + { + edits.emplace_back(one.range, one.text); + } + doc.replaceMany(std::move(edits)); + placed.clear(); + for (const std::optional& one : all.selections) + { + placed += one ? llformat("%d:%d-%d:%d ", one->begin.line, one->begin.column, one->end.line, one->end.column) : std::string("- "); + } + return doc.text(); + } + + static ALTextRange at(S32 line, S32 column) + { + return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); + } + typedef test_group altextediting_group; typedef altextediting_group::object altextediting_object; altextediting_group altextediting_instance("altextediting"); @@ -166,4 +193,108 @@ namespace tut ensure_equals("copied to the top", applied(text, first), std::string("b\na\nb\nc\nd\ne")); ensure("the caret on it", first.caret == ALTextPos(0, 0)); } + + template<> template<> + void altextediting_object::test<6>() + { + set_test_name("groups made as one: each selection moved by the groups before it; one over a group before it left out; one that replaces nothing placed by those before"); + // Typed at two carets on a line, and a line broken at a third. + std::vector groups(3); + groups[0].replacements.push_back({ at(0, 1), "XY" }); + groups[0].placed.emplace_back(0, at(0, 3)); + groups[1].replacements.push_back({ at(0, 5), "XY" }); + groups[1].placed.emplace_back(1, at(0, 7)); + groups[2].replacements.push_back({ at(1, 2), "\n" }); + groups[2].placed.emplace_back(2, at(2, 0)); + std::string placed; + ensure_equals("all made", combined("abc def\nghi", groups, 3, placed), std::string("aXYbc dXYef\ngh\ni")); + ensure_equals("each moved by those before it", placed, std::string("0:3-0:3 0:9-0:9 2:0-2:0 ")); + + // A selection deleted over where the second typed: the one that + // comes first in the text stands, the second is left out. + Group taken; + taken.replacements.push_back({ ALTextRange(ALTextPos(0, 4), ALTextPos(0, 6)), "" }); + taken.placed.emplace_back(3, at(0, 4)); + // And a caret that moves over a closer, replacing nothing. + Group over; + over.placed.emplace_back(4, at(1, 1)); + groups.push_back(taken); + groups.push_back(over); + ensure_equals("the second left out", combined("abc def\nghi", groups, 5, placed), std::string("aXYbc f\ngh\ni")); + ensure_equals("its caret not placed, the others moved", placed, std::string("0:3-0:3 - 2:0-2:0 0:6-0:6 1:1-1:1 ")); + } + + template<> template<> + void altextediting_object::test<7>() + { + set_test_name("selections gathered into runs over the same lines, or over lines next to each other too"); + const std::vector selections = { at(0, 0), ALTextRange(ALTextPos(1, 0), ALTextPos(2, 3)), at(2, 5), at(4, 0), at(5, 1), + ALTextRange(ALTextPos(6, 0), ALTextPos(8, 0)), at(8, 0) }; + const auto said = [](const std::vector& runs) { + std::string out; + for (const LineRun& run : runs) + { + out += llformat("%d-%d:", run.first, run.last); + for (const size_t i : run.selections) + { + out += llformat("%d", static_cast(i)); + } + out += " "; + } + return out; + }; + ensure_equals("over the same lines", said(lineRuns(selections, false)), std::string("0-0:0 1-2:12 4-4:3 5-5:4 6-7:5 8-8:6 ")); + ensure_equals("and next to each other", said(lineRuns(selections, true)), std::string("0-2:012 4-8:3456 ")); + } + + template<> template<> + void altextediting_object::test<8>() + { + set_test_name("lines duplicated and moved at several selections: once for those on the same lines, each selection with its lines"); + std::string placed; + ensure_equals("two carets on a line copy it once", combined("a\nb\nc\nd", duplicateLines(ALTextDocument("a\nb\nc\nd"), { at(0, 0), at(0, 1), at(2, 0) }), 3, placed), + std::string("a\na\nb\nc\nc\nd")); + ensure_equals("each on its copy", placed, std::string("1:0-1:0 1:1-1:1 4:0-4:0 ")); + + const std::string text = "0\n1\n2\n3\n4\n5"; + const ALTextDocument doc(text); + ensure_equals("lines next to each other move as one", combined(text, moveLines(doc, { at(1, 0), at(2, 0), at(4, 0) }, -1), 3, placed), + std::string("1\n2\n0\n4\n3\n5")); + ensure_equals("each with its line", placed, std::string("0:0-0:0 1:0-1:0 3:0-3:0 ")); + ensure("nothing where one is at the top already", moveLines(doc, { at(0, 0), at(3, 0) }, -1).empty()); + ensure("nor at the bottom", moveLines(doc, { at(3, 0), at(5, 1) }, 1).empty()); + } + + template<> template<> + void altextediting_object::test<9>() + { + set_test_name("lines deleted and joined at several selections: lines next to each other as one, each caret where its run leaves it"); + std::string placed; + const std::string text = "0\n1\n2\n3\n4"; + ensure_equals("the two in the middle and the last", combined(text, deleteLines(ALTextDocument(text), { at(1, 0), at(2, 0), at(4, 0) }), 3, placed), + std::string("0\n3")); + ensure_equals("the carets on the line that took their place", placed, std::string("1:0-1:0 1:0-1:0 1:0-1:0 ")); + + const std::string lines = "a\n b\nc\n d\ne"; + ensure_equals("each caret's line and the next joined", combined(lines, joinLines(ALTextDocument(lines), { at(0, 0), at(2, 0) }), 2, placed), + std::string("a b\nc d\ne")); + ensure_equals("each where its join is", placed, std::string("0:1-0:1 1:1-1:1 ")); + ensure_equals("carets on lines a join shares joined as one run", combined(lines, joinLines(ALTextDocument(lines), { at(0, 0), at(1, 1) }), 2, placed), + std::string("a b c\n d\ne")); + } + + template<> template<> + void altextediting_object::test<10>() + { + set_test_name("comments in or out the same way at every selection: in where any line is not, each caret kept in its text and each selection its lines whole"); + std::string placed; + const std::string text = "a\n // b\nc"; + const std::string in = combined(text, toggleComment(ALTextDocument(text), { at(0, 1), at(1, 5), ALTextRange(ALTextPos(2, 0), ALTextPos(2, 1)) }, "//"), 3, placed); + ensure_equals("in at all of them, the commented line too", in, std::string("// a\n // // b\n// c")); + ensure_equals("carets along, the selection its line whole", placed, std::string("0:4-0:4 1:8-1:8 2:0-2:4 ")); + const std::string out = combined(in, toggleComment(ALTextDocument(in), { at(0, 4), at(2, 2) }, "//"), 2, placed); + ensure_equals("out where every one is", out, std::string("a\n // // b\nc")); + ensure_equals("a caret inside the token where it began", placed, std::string("0:1-0:1 2:0-2:0 ")); + ensure("nothing where no line says anything", toggleComment(ALTextDocument("\n \n"), { at(0, 0), at(1, 1) }, "//").empty()); + } } diff --git a/indra/llui/tests/altextindent_test.cpp b/indra/llui/tests/altextindent_test.cpp index d168303fdb..40849fc1c0 100644 --- a/indra/llui/tests/altextindent_test.cpp +++ b/indra/llui/tests/altextindent_test.cpp @@ -423,4 +423,37 @@ namespace tut std::string("a\n\n\nb\n c")); ensure_equals("out by two", applied(ALTextIndent::shiftLines(doc, 0, 4, 2, false, hard)), std::string("a\n\n\nb\nc")); } + + template<> template<> + void altextindent_object::test<13>() + { + set_test_name("lines in and out at several selections: once for those over the same lines, each selection moved with its lines"); + const std::string text = "a\nb\nc\n d"; + const ALTextDocument doc(text); + const auto made = [](const std::string& before, const std::vector& groups, std::string& placed) { + ALTextEditing::Combined combined = ALTextEditing::combine(groups, 3); + ALTextDocument after(before); + std::vector> edits; + for (const ALTextEditing::Replacement& one : combined.replacements) + { + edits.emplace_back(one.range, one.text); + } + after.replaceMany(std::move(edits)); + placed.clear(); + for (const std::optional& one : combined.selections) + { + placed += one ? llformat("%d:%d-%d:%d ", one->begin.line, one->begin.column, one->end.line, one->end.column) : std::string("- "); + } + return after.text(); + }; + std::string placed; + // Two selections over lines 0 and 1, which share line 1, and one on + // line 3. + const std::vector selections = { ALTextRange(ALTextPos(0, 0), ALTextPos(1, 1)), ALTextRange(ALTextPos(1, 1), ALTextPos(1, 0)), + ALTextRange(ALTextPos(3, 5), ALTextPos(3, 4)) }; + ensure_equals("each line in once", made(text, indentLines(doc, selections, true, spaces), placed), std::string(" a\n b\nc\n d")); + ensure_equals("each selection moved with its lines", placed, std::string("0:4-1:5 1:5-1:4 3:9-3:8 ")); + ensure_equals("and out", made(text, indentLines(doc, selections, false, spaces), placed), std::string("a\nb\nc\nd")); + ensure_equals("those with nothing to take left to slide; the other moved", placed, std::string("- - 3:1-3:0 ")); + } } diff --git a/indra/llui/tests/altextview_test.cpp b/indra/llui/tests/altextview_test.cpp index 268ea4a1c3..a35c1871da 100644 --- a/indra/llui/tests/altextview_test.cpp +++ b/indra/llui/tests/altextview_test.cpp @@ -65,6 +65,8 @@ namespace ll_test static void misspellingBudget(ALTextView& view, F32 seconds) { view.mMisspellingBudget = seconds; } static bool seeking(const ALTextView& view) { return view.mMisspellingSought.has_value(); } static void trimLayout(ALTextView& view) { view.trimLayout(); } + // Every selection, the main one among them, in the order they begin. + static std::vector selections(const ALTextView& view) { return view.selectionsInOrder(); } static S32 heldMost() { return ALTextView::LAYOUT_HELD_MOST; } // What a frame does before it draws, of what a test reaches: // Next Misspelling gone on with, and the primary selection offered. @@ -1955,9 +1957,9 @@ namespace tut ensure_equals("two besides it", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(1, 0), ALTextRange(ALTextPos(1, 6), ALTextPos(1, 11)) })); ensure("the main one where it was", v.selection() == caretRange(0, 0)); - type("xy"); - key(KEY_RETURN); - ensure_equals("typed at the main caret only", v.text(), std::string("xy\nalpha beta\ngamma delta\n")); + // Put in at the main caret alone, as a caller does. + v.insertText("xy\n"); + ensure_equals("put in at the main caret only", v.text(), std::string("xy\nalpha beta\ngamma delta\n")); ensure_equals("the others a line down", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(2, 0), ALTextRange(ALTextPos(2, 6), ALTextPos(2, 11)) })); @@ -2007,4 +2009,178 @@ namespace tut v.undo(); ensure_equals("and the step before puts its own back", rangesSaid(v.otherSelections()), rangesSaid(before)); } + + template<> template<> + void altextview_object::test<63>() + { + set_test_name("typing at every caret: each selection typed over, a run of it one step to undo, and undo and redo putting every caret back"); + ALTextView& v = make("one\ntwo\nthree\n"); + const std::vector before = { caretRange(1, 3), ALTextRange(ALTextPos(2, 0), ALTextPos(2, 5)) }; + v.setSelections(caretRange(0, 3), before); + type("ab"); + ensure_equals("at each", v.text(), std::string("oneab\ntwoab\nab\n")); + ensure("the main caret after it", v.selection() == caretRange(0, 5)); + const std::vector after = { caretRange(1, 5), caretRange(2, 2) }; + ensure_equals("each other after it", rangesSaid(v.otherSelections()), rangesSaid(after)); + v.undo(); + ensure_equals("one step", v.text(), std::string("one\ntwo\nthree\n")); + ensure("the main caret back", v.selection() == caretRange(0, 3)); + ensure_equals("and the others", rangesSaid(v.otherSelections()), rangesSaid(before)); + v.redo(); + ensure_equals("forward", v.text(), std::string("oneab\ntwoab\nab\n")); + ensure_equals("each after it again", rangesSaid(v.otherSelections()), rangesSaid(after)); + } + + template<> template<> + void altextview_object::test<64>() + { + set_test_name("Backspace and Delete at every caret: a run of them one step apart from the typing; what is selected taken whole"); + ALTextView& v = make("abc\nabc\nabc"); + v.setSelections(caretRange(0, 3), { caretRange(1, 3), caretRange(2, 3) }); + type("x"); + key(KEY_BACKSPACE); + key(KEY_BACKSPACE); + ensure_equals("two taken at each", v.text(), std::string("ab\nab\nab")); + v.undo(); + ensure_equals("the run of them one step", v.text(), std::string("abcx\nabcx\nabcx")); + v.undo(); + ensure_equals("the typing another", v.text(), std::string("abc\nabc\nabc")); + + v.setSelections(caretRange(0, 0), { caretRange(1, 1), ALTextRange(ALTextPos(2, 3), ALTextPos(2, 1)) }); + key(KEY_DELETE); + ensure_equals("one forward at each caret, the selection whole", v.text(), std::string("bc\nac\na")); + ensure_equals("each where it erased", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(1, 1), caretRange(2, 1) })); + } + + template<> template<> + void altextview_object::test<65>() + { + set_test_name("Return and Tab at every caret: each line broken and indented as it was; a tab at each caret, the lines of each selection a level in, and out with Shift-Tab"); + ALTextView& v = make(" one\n two"); + v.setSelections(caretRange(0, 5), { caretRange(1, 5) }); + key(KEY_RETURN); + ensure_equals("each line broken, the new one as far in", v.text(), std::string(" one\n \n two\n ")); + ensure("the main caret on its new line", v.selection() == caretRange(1, 2)); + ensure_equals("the other on its", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(3, 2) })); + + v.setText("a\nb\nc"); + v.setSelections(caretRange(0, 1), { caretRange(2, 0) }); + key(KEY_TAB); + ensure_equals("a tab to the next stop at each caret", v.text(), std::string("a \nb\n c")); + v.setText("a\nb\nc"); + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(1, 1)), { caretRange(2, 1) }); + key(KEY_TAB); + ensure_equals("the selection's lines in, a tab at the caret", v.text(), std::string(" a\n b\nc ")); + ensure("the selection with its lines", v.selection() == ALTextRange(ALTextPos(0, 4), ALTextPos(1, 5))); + key(KEY_TAB, MASK_SHIFT); + ensure_equals("out again, every selection's lines", v.text(), std::string("a\nb\nc ")); + v.undo(); + v.undo(); + ensure_equals("each a step", v.text(), std::string("a\nb\nc")); + } + + template<> template<> + void altextview_object::test<66>() + { + set_test_name("the caret's motions at every caret: up and down each keeping its own column, Home, End and the arrows, a selection extended at each and collapsed; those a motion brings together one"); + ALTextView& v = make("hello world\nhi\nhello world\nx\nhello world"); + v.setSelections(caretRange(0, 8), { caretRange(2, 8) }); + key(KEY_DOWN); + ensure("the main caret down to the short line's end", v.selection() == caretRange(1, 2)); + ensure_equals("the other to its", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(3, 1) })); + key(KEY_DOWN); + ensure("each back in its own column", v.selection() == caretRange(2, 8)); + ensure_equals("the other too", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(4, 8) })); + key(KEY_HOME); + key(KEY_RIGHT, MASK_SHIFT); + key(KEY_RIGHT, MASK_SHIFT); + ensure("extended at the main", v.selection() == ALTextRange(ALTextPos(2, 0), ALTextPos(2, 2))); + ensure_equals("and at the other", rangesSaid(v.otherSelections()), rangesSaid({ ALTextRange(ALTextPos(4, 0), ALTextPos(4, 2)) })); + key(KEY_LEFT); + ensure("collapsed to its start", v.selection() == caretRange(2, 0)); + ensure_equals("both", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(4, 0) })); + key(KEY_END); + ensure_equals("to each line's end", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(4, 11) })); + v.perform(ALEditorCommand::MoveWordLeft); + ensure("a word back at each", v.selection() == caretRange(2, 6)); + v.perform(ALEditorCommand::MoveDocStart); + ensure("all at the start, one", v.selection() == caretRange(0, 0) && !v.hasOtherSelections()); + } + + template<> template<> + void altextview_object::test<67>() + { + set_test_name("the commands over lines at every caret: duplicated, moved, deleted, joined and commented once for each line, every caret going with its line"); + ALTextView& v = make("a\nb\nc\nd\ne"); + v.setSelections(caretRange(0, 0), { caretRange(0, 1), caretRange(3, 0) }); + v.perform(ALEditorCommand::DuplicateLine); + ensure_equals("each line once", v.text(), std::string("a\na\nb\nc\nd\nd\ne")); + ensure_equals("each caret on its copy", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(1, 0), caretRange(1, 1), caretRange(5, 0) })); + v.perform(ALEditorCommand::MoveLineDown); + ensure_equals("each copy down past the next", v.text(), std::string("a\nb\na\nc\nd\ne\nd")); + v.undo(); + v.undo(); + ensure_equals("each a step", v.text(), std::string("a\nb\nc\nd\ne")); + v.setSelections(caretRange(0, 0), { caretRange(3, 0) }); + v.perform(ALEditorCommand::DeleteLine); + ensure_equals("each caret's line gone", v.text(), std::string("b\nc\ne")); + ensure_equals("each on the line that took its place", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(0, 0), caretRange(2, 0) })); + v.perform(ALEditorCommand::JoinLines); + ensure_equals("each caret's line joined to the next", v.text(), std::string("b c\ne")); + + v.setSyntax("lsl"); + v.setText("a;\n b;\nc;"); + v.setSelections(caretRange(0, 1), { caretRange(2, 2) }); + v.perform(ALEditorCommand::ToggleComment); + ensure_equals("in at each caret's line", v.text(), std::string("// a;\n b;\n// c;")); + v.perform(ALEditorCommand::ToggleComment); + ensure_equals("and out", v.text(), std::string("a;\n b;\nc;")); + ensure_equals("each caret where it was in its text", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(0, 1), caretRange(2, 2) })); + } + + template<> template<> + void altextview_object::test<68>() + { + set_test_name("copy, cut and paste at every selection: copied one to a line, and pasted one to each where there is a line for each, else the whole at each"); + LLClipboard& clipboard = LLClipboard::instance(); + ALTextView& v = make("one two\nthree four"); + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 3)), { ALTextRange(ALTextPos(1, 0), ALTextPos(1, 5)) }); + v.copy(); + std::string clip; + ensure("copied", clipboard.pasteFromClipboard(clip)); + ensure_equals("one to a line, in order", clip, std::string("one\nthree")); + v.setSelections(caretRange(0, 7), { caretRange(1, 10) }); + v.paste(); + ensure_equals("a line at each", v.text(), std::string("one twoone\nthree fourthree")); + ensure_equals("each caret after its line", rangesSaid(ll_test::TextViewProbe::selections(v)), rangesSaid({ caretRange(0, 10), caretRange(1, 15) })); + + v.setText("x\ny\nz"); + v.setSelections(caretRange(0, 1), { caretRange(1, 1), caretRange(2, 1) }); + v.paste(); + ensure_equals("two lines for three carets: the whole at each", v.text(), std::string("xone\nthree\nyone\nthree\nzone\nthree")); + + v.setText("a1 a2 a3"); + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 2)), { ALTextRange(ALTextPos(0, 6), ALTextPos(0, 8)) }); + v.cut(); + ensure_equals("each cut", v.text(), std::string(" a2 ")); + ensure("and copied", clipboard.pasteFromClipboard(clip) && clip == "a1\na3"); + v.undo(); + ensure_equals("one step", v.text(), std::string("a1 a2 a3")); + } + + template<> template<> + void altextview_object::test<69>() + { + set_test_name("a command that acts at one place acts at the main caret and lets the others go; select all takes them in"); + ALTextView& v = make("one\ntwo\nthree"); + v.setSelections(caretRange(1, 0), { caretRange(0, 0), caretRange(2, 0) }); + v.perform(ALEditorCommand::NextMisspelling); + ensure("the others let go", !v.hasOtherSelections()); + ensure("the main one where it was", v.selection() == caretRange(1, 0)); + v.setSelections(caretRange(1, 0), { caretRange(0, 0), caretRange(2, 0) }); + v.perform(ALEditorCommand::Fold); + ensure("folding keeps them", v.otherSelections().size() == 2); + v.selectAll(); + ensure("select all takes them in", !v.hasOtherSelections() && v.selection().normalised() == ALTextRange(ALTextPos(0, 0), ALTextPos(2, 5))); + } } From b0dfad8263ad5c9f7f138e8ba47b25cd2fb891b9 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 14:34:04 -0400 Subject: [PATCH 03/41] Carets are added by Alt-click, Control-Alt with an arrow, and Ctrl-D Alt-click -- Option-click on a Mac -- puts a caret where it is pressed, or takes away the one there, and any other click is one caret again. Control-Alt with up or down -- Command-Option on a Mac -- adds a caret on the row above or below each one, each keeping its column past a short line, as a column grows. Escape goes back to one caret, keeping its selection, before it lets the selection go; going to a place from a list is one caret too. The newest caret is the main one, which the view follows. Add Caret Above and Below are in the Selection menu and take keys of their own. Select Next Occurrence adds the next place as a selection of its own, and Change All selects every place, rather than lighting them as a snippet's mirrors; with nothing selected at several carets, the first takes the word at each. The find bar selects every match with Alt-Return. The completion list stays open at several carets, and what is chosen from it goes in at each, over what reads there as the main caret's word does, each caret on the first parameter or stop. Co-Authored-By: Claude Opus 5.5 --- indra/llui/alcodeeditor.cpp | 195 ++++++++++++------ indra/llui/alcodeeditor.h | 29 ++- indra/llui/alfindbar.cpp | 9 +- indra/llui/alfindbar.h | 10 +- indra/llui/alkeymap.cpp | 5 +- indra/llui/alkeymap.h | 10 +- indra/llui/altextview.cpp | 146 ++++++++++++- indra/llui/altextview.h | 13 +- indra/llui/tests/alcodeeditor_test.cpp | 53 ++++- indra/llui/tests/altextview_test.cpp | 61 ++++++ indra/newview/alfloaterscriptstudio.cpp | 2 + .../default/xui/en/floater_script_studio.xml | 9 + .../xui/en/panel_script_studio_keys.xml | 2 + 13 files changed, 452 insertions(+), 92 deletions(-) diff --git a/indra/llui/alcodeeditor.cpp b/indra/llui/alcodeeditor.cpp index 857ddc3cb4..d685b6b766 100644 --- a/indra/llui/alcodeeditor.cpp +++ b/indra/llui/alcodeeditor.cpp @@ -51,6 +51,7 @@ #include #include +#include #include static LLDefaultChildRegistry::Register r("code_editor"); @@ -3288,6 +3289,31 @@ bool ALCodeEditor::acceptCompletion() void ALCodeEditor::complete(const Completion& chosen, const ALTextRange& range) { + if (hasOtherSelections()) + { + // What it replaces either side of the main caret, which each other + // caret has replaced where its text reads the same. + const ALTextPos at = caret(); + const std::string before = range.begin < at ? document().text(ALTextRange(range.begin, at)) : std::string(); + const std::string after = at < range.end ? document().text(ALTextRange(at, range.end)) : std::string(); + undoJournal().beginGroup(); + editEach([&](size_t, const ALTextRange& selection) { + ALTextRange over = selection.normalised(); + if (over.empty()) + { + const ALTextPos from(over.end.line, over.end.column - static_cast(before.size())); + const ALTextPos to(over.end.line, over.end.column + static_cast(after.size())); + if (from.column >= 0 && document().text(ALTextRange(from, over.end)) == before && document().text(ALTextRange(over.end, to)) == after) + { + over = ALTextRange(from, to); + } + } + return std::optional(completionAt(chosen, over)); + }); + undoJournal().endGroup(); + setFocus(true); + return; + } setSelection(range); if (!chosen.snippet.empty()) { @@ -3343,6 +3369,60 @@ void ALCodeEditor::complete(const Completion& chosen, const ALTextRange& range) setFocus(true); } +ALTextEditing::Change ALCodeEditor::completionAt(const Completion& chosen, const ALTextRange& over) +{ + ALTextEditing::Change one; + if (!chosen.snippet.empty()) + { + // Indented as the line it goes into, as insertSnippet has it; the + // caret on its first stop. + const std::string& line = document().line(over.begin.line); + const std::string indent = line.substr(0, std::min(line.size(), line.find_first_not_of(" \t"))); + const ALSnippetSession::Expansion expanded = ALSnippetSession::expand(chosen.snippet, over.begin, indent, + ALTextIndent::indentUnit(indent, { getTabWidth(), getSoftTabs() })); + const ALTextRange land = expanded.stops.empty() ? expanded.landing : expanded.stops.front(); + one.replacements.push_back({ over, expanded.text }); + one.selects = true; + one.anchor = land.begin; + one.caret = land.end; + return one; + } + // A function called, as complete() calls it: its first parameter + // chosen, else the caret between its brackets where it takes anything. + const std::string& line = document().line(over.end.line); + const bool called = chosen.kind == ALSyntaxKind::Function && !(over.end.column < static_cast(line.size()) && line[over.end.column] == '('); + if (!called) + { + one.replacements.push_back({ over, chosen.text }); + one.caret = ALTextEditing::endOf(over.begin, chosen.text); + return one; + } + const std::vector names = parameterNames(chosen.detail, chosen.text); + const size_t open = parameterListAt(chosen.detail, chosen.text); + const size_t after = open == std::string::npos ? std::string::npos : chosen.detail.find_first_not_of(' ', open + 1); + const bool takes = open == std::string::npos || after == std::string::npos || chosen.detail[after] != ')'; + std::string call = chosen.text + "("; + for (size_t i = 0; i < names.size(); ++i) + { + call += (i > 0 ? ", " : "") + names[i]; + } + call += ")"; + one.replacements.push_back({ over, call }); + const ALTextPos end = ALTextEditing::endOf(over.begin, call); + if (!names.empty()) + { + const S32 from = over.begin.column + static_cast(chosen.text.size()) + 1; + one.selects = true; + one.anchor = ALTextPos(over.begin.line, from); + one.caret = ALTextPos(over.begin.line, from + static_cast(names.front().size())); + } + else + { + one.caret = takes ? ALTextPos(end.line, end.column - 1) : end; + } + return one; +} + // --- snippets ------------------------------------------------------------------------ void ALCodeEditor::insertSnippet(std::string_view body) @@ -3405,7 +3485,8 @@ void ALCodeEditor::syncMirrors(S32 index, bool grouped) afterEdit(); } -std::vector ALCodeEditor::placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most) const +std::vector ALCodeEditor::placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most, + const std::vector& taken_in) const { std::vector out; const ALTextDocument& doc = document(); @@ -3414,23 +3495,12 @@ std::vector ALCodeEditor::placesOf(const std::string& wanted, bool { return out; } - // Taken already: the stop and its mirrors. - const auto taken = [this](const ALTextRange& range) { - for (const ALTextRange& stop : mSnippet.stops()) - { - if (range.begin < stop.end && stop.begin < range.end) - { - return true; - } - } - for (const ALSnippetSession::Mirror& mirror : mSnippet.mirrors()) - { - if (range.begin < mirror.range.end && mirror.range.begin < range.end) - { - return true; - } - } - return false; + // Taken already: any over one of the selections. + const auto taken = [&taken_in](const ALTextRange& range) { + return std::any_of(taken_in.begin(), taken_in.end(), [&range](const ALTextRange& one) { + const ALTextRange r = one.normalised(); + return range.begin < r.end && r.begin < range.end; + }); }; // Each line once, from `from` round to it again. for (S32 step = 0; step <= count && out.size() < most; ++step) @@ -3472,7 +3542,20 @@ bool ALCodeEditor::selectNextOccurrence() { return false; } - setSelection(name); + // And at every other caret, its own. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + for (ALTextRange& one : all) + { + if (one.empty()) + { + const ALTextRange word = identifierAt(one.end); + one = word.empty() ? document().wordAt(one.end) : word; + } + } + all[main] = name; + placeSelections(all, main); + setSelection(selection()); mOccurrenceName = selection().normalised(); return true; } @@ -3480,26 +3563,19 @@ bool ALCodeEditor::selectNextOccurrence() { return false; } - const bool going = mSnippet.live() && mSnippet.stops().size() == 1 && mSnippet.stops()[0] == taken; - if (!going) - { - mSnippet.start({ taken }, taken.end); - mSnippet.setLive(true); - } - // After the last place taken, going round: they are taken in turn. - const ALTextPos from = mSnippet.mirrors().empty() ? taken.end : mSnippet.mirrors().back().range.end; - const std::vector next = placesOf(document().text(taken), taken == mOccurrenceName, from, 1); + // After the main selection, going round, past those taken: the next + // one the main one, so that they are taken in turn. + std::vector all = selectionsInOrder(); + const bool whole = taken == mOccurrenceName; + const std::vector next = placesOf(document().text(taken), whole, taken.end, 1, all); if (next.empty()) { - if (!going) - { - mSnippet.clear(); - } return false; } - mSnippet.addMirror({ 0, next.front() }); - setSelection(taken); - scrollToShow(next.front().begin); + all.push_back(next.front()); + placeSelections(all, all.size() - 1); + setSelection(selection()); + mOccurrenceName = whole ? selection().normalised() : ALTextRange(); return true; } @@ -3516,19 +3592,16 @@ bool ALCodeEditor::changeAllOccurrences() { return false; } - mSnippet.start({ taken }, taken.end); - const std::vector places = placesOf(document().text(taken), whole, taken.end, 1000); - if (places.empty()) + // Every place, the one taken the main selection; the others there + // were let go of. + std::vector all = placesOf(document().text(taken), whole, taken.end, std::numeric_limits::max(), { taken }); + if (all.empty()) { - mSnippet.clear(); return false; } - for (const ALTextRange& place : places) - { - mSnippet.addMirror({ 0, place }); - } - mSnippet.setLive(true); - setSelection(taken); + all.push_back(taken); + placeSelections(all, all.size() - 1); + setSelection(selection()); return true; } @@ -4149,11 +4222,12 @@ bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) } }); hideCard(); - // At several carets, nothing the typing puts up -- the list, the - // signature, a snippet's stops -- is in play: each is about one place. + // At several carets the signature and a snippet's stops are not in + // play, each being about one place; the list is the main caret's. if (hasOtherSelections() && typingText()) { - dropTyping(); + hideSignature(); + clearPlaceholders(); } // The fixes listed take the keys that walk them and take one, in any // mode a modal keymap is in -- the list was asked for; any other key @@ -4332,23 +4406,22 @@ bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) bool ALCodeEditor::handleUnicodeCharHere(llwchar uni_char) { const bool typing = typingText(); - if (typing && hasOtherSelections()) + const bool was_open = completionOpen(); + // At several carets the signature and a snippet's stops, each about + // one place, go; the list stays, the main caret's, and what is chosen + // from it goes in at each. + const bool several = typing && hasOtherSelections(); + if (several) { - // At every caret: the list, the signature and a snippet's stops are - // each about one place, and go. - dropTyping(); - undoJournal().beginTyping(selection()); - const bool typed = - (mAutoClose && uni_char < 0x80 && !isReadOnly() && typePairs(static_cast(uni_char))) || ALTextView::handleUnicodeCharHere(uni_char); - undoJournal().endTyping(); - return typed; + hideSignature(); + clearPlaceholders(); } - const bool was_open = completionOpen(); // The pair, the character and its outdent one key typed, one with the // typing around it -- and every place of a text being changed at once // brought up to it in the same step. undoJournal().beginTyping(selection()); - const bool paired = typing && mAutoClose && uni_char < 0x80 && !isReadOnly() && typePair(static_cast(uni_char)); + const bool paired = typing && mAutoClose && uni_char < 0x80 && !isReadOnly() && + (several ? typePairs(static_cast(uni_char)) : typePair(static_cast(uni_char))); const bool typed = paired || ALTextView::handleUnicodeCharHere(uni_char); if (typed && mSnippet.live()) { @@ -4390,7 +4463,7 @@ bool ALCodeEditor::handleUnicodeCharHere(llwchar uni_char) // A call begins, moves on to its next argument, or ends: asked again // then, and only then -- within an argument, the parameter shown // follows the caret here. - if (mSignatureRequest && (uni_char == '(' || uni_char == ',' || uni_char == ')')) + if (!several && mSignatureRequest && (uni_char == '(' || uni_char == ',' || uni_char == ')')) { mSignatureRequest(caret()); } @@ -4694,6 +4767,7 @@ bool ALCodeEditor::handleMouseDown(S32 x, S32 y, MASK mask) const ALTextPos from(line, 0); const ALTextPos to = line < last ? ALTextPos(line + 1, 0) : ALTextPos(line, static_cast(document().line(line).size())); setFocus(true); + singleSelection(); if ((mask & MASK_SHIFT) && hasSelection()) { const ALTextRange was = selection(); @@ -4719,6 +4793,7 @@ bool ALCodeEditor::handleMouseDown(S32 x, S32 y, MASK mask) if (mask == MASK_CONTROL && (mSymbolRequest || mLinkRequest) && text.pointInRect(x, y)) { const ALTextPos at = posAtLocal(x, y, false); + singleSelection(); if (mLinkRequest && !mLinkRequest(at, false).empty()) { setFocus(true); diff --git a/indra/llui/alcodeeditor.h b/indra/llui/alcodeeditor.h index f6b0629d0f..69ba6b2a55 100644 --- a/indra/llui/alcodeeditor.h +++ b/indra/llui/alcodeeditor.h @@ -473,7 +473,11 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures const std::vector& completions() const { return mCompletionModel.list(); } S32 chosenCompletion() const; // What accepting does, for whoever has a completion in hand without - // the list: the completion in place of the range. + // the list: the completion in place of the range. Where there are + // several carets it goes in at each -- in place of what reads as the + // range does on either side of it, else at it -- each with its own + // indentation, and each caret on its first parameter or stop, with + // none to Tab through. void complete(const Completion& chosen, const ALTextRange& range); // --- snippets -------------------------------------------------------------------- @@ -540,13 +544,12 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures // signature and the stops. void dropTyping(); - // Every place of a text changed at once, until there are several - // carets: the selection a stop, and the places that read as it does - // its mirrors, brought up as each key is typed and lit while they - // are; Escape, Tab, or the caret off the stop's line lets them go. + // Every place of a text changed at once, each a selection of its own. // Select Next Occurrence takes the name at the caret, then adds the - // next place after the last one taken, going round; Change All takes - // every place at once. A name taken so matches whole names only. + // next place after the main selection, going round, which becomes the + // main one; Change All selects every place at once, the main one kept + // main and the others it had let go. A name taken so matches whole + // names only. bool selectNextOccurrence(); bool changeAllOccurrences(); void undo() override; @@ -843,6 +846,9 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures // nothing at; false where it pairs nothing at any. bool typePair(char c); bool typePairs(char c); + // A completion in place of a stretch, as a change: the snippet, the + // call or the name, and where the selection is after. + ALTextEditing::Change completionAt(const Completion& chosen, const ALTextRange& over); // What a character typed at a selection does as a pair: what it // replaces and where the selection goes, as a change; whether that // goes over a closer typing put in, or puts one in at the caret after. @@ -1074,11 +1080,12 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures // own, or as part of the key being typed. void syncMirrors(S32 index, bool grouped = true); // The places after `from` that read as `wanted`, going round, whole - // names only where asked, none of the stop's or its mirrors'; at most - // `most` of them. - std::vector placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most) const; + // names only where asked, none over any of `taken` -- the selections + // there are; at most `most` of them. + std::vector placesOf(const std::string& wanted, bool whole, const ALTextPos& from, size_t most, + const std::vector& taken) const; // The name Select Next Occurrence took at the caret, whose places are - // whole names only. + // whole names only: the main selection while it is one of them. ALTextRange mOccurrenceName; // The placeholders let go of where the caret has left the lines they // and where the call or the snippet ends are on. diff --git a/indra/llui/alfindbar.cpp b/indra/llui/alfindbar.cpp index 9099d5dcd0..c007315984 100644 --- a/indra/llui/alfindbar.cpp +++ b/indra/llui/alfindbar.cpp @@ -490,13 +490,18 @@ bool ALFindBar::handleKeyHere(KEY key, MASK mask) } } // Return goes on through the fields and buttons by themselves; with - // Shift, back; with REPLACE_ALL_MASK, every one replaced where the - // second row is out. + // Shift, back; with Alt, every match selected; with REPLACE_ALL_MASK, + // every one replaced where the second row is out. if (key == KEY_RETURN && mask == MASK_SHIFT) { mPrevious(); return true; } + if (key == KEY_RETURN && mask == MASK_ALT) + { + mSelectAll(); + return true; + } if (key == KEY_RETURN && mask == REPLACE_ALL_MASK) { // Only with the replacement in sight: what it is was seen. diff --git a/indra/llui/alfindbar.h b/indra/llui/alfindbar.h index 75ae9bf8a1..e54858dd2a 100644 --- a/indra/llui/alfindbar.h +++ b/indra/llui/alfindbar.h @@ -48,9 +48,10 @@ class LLTextBox; // in turn, and Space or Return presses the one it is on; and while the // bar has the keyboard, Alt with C, W, R or L -- Command and Option with // them on a Mac -- turns case, whole words, patterns or the selection on -// or off, as the modern editors have it; Shift and Return goes back, and -// Control, Alt and Return -- Command, Option and Return -- replaces every -// one. +// or off, as the modern editors have it; Shift and Return goes back, +// Alt and Return -- Option and Return -- selects every match, each a +// selection of its own, and Control, Alt and Return -- Command, Option and +// Return -- replaces every one. class ALFindBar : public LLPanel { public: @@ -100,6 +101,7 @@ class ALFindBar : public LLPanel boost::signals2::connection onPrevious(const signal_t::slot_type& cb) { return mPrevious.connect(cb); } boost::signals2::connection onReplace(const signal_t::slot_type& cb) { return mReplace.connect(cb); } boost::signals2::connection onReplaceAll(const signal_t::slot_type& cb) { return mReplaceAll.connect(cb); } + boost::signals2::connection onSelectAll(const signal_t::slot_type& cb) { return mSelectAll.connect(cb); } boost::signals2::connection onClose(const signal_t::slot_type& cb) { return mClose.connect(cb); } void draw() override; @@ -139,5 +141,5 @@ class ALFindBar : public LLPanel Flat* mReplaceOne = nullptr; Flat* mReplaceEvery = nullptr; - signal_t mChanged, mNext, mPrevious, mReplace, mReplaceAll, mClose; + signal_t mChanged, mNext, mPrevious, mReplace, mReplaceAll, mSelectAll, mClose; }; diff --git a/indra/llui/alkeymap.cpp b/indra/llui/alkeymap.cpp index e853e4921c..e99ff8ce31 100644 --- a/indra/llui/alkeymap.cpp +++ b/indra/llui/alkeymap.cpp @@ -47,7 +47,7 @@ const char* alEditorCommandName(ALEditorCommand command) "join_lines", "previous_change", "next_change", "next_function", "previous_function", "select_function", "go_to_bracket", "insert_line_below", "insert_line_above", "select_line", "move_subword_left", "move_subword_right", "select_subword_left", "select_subword_right", "expand_selection", "shrink_selection", - "select_next_occurrence", "change_all_occurrences", + "select_next_occurrence", "change_all_occurrences", "add_caret_above", "add_caret_below", }; static_assert(sizeof(NAMES) / sizeof(NAMES[0]) == static_cast(ALEditorCommand::COUNT), "every command has a name"); const size_t index = static_cast(command); @@ -282,6 +282,9 @@ ALKeymap ALKeymap::standard() map.bind('D', MASK_CONTROL, C::SelectNextOccurrence); map.bind('L', MASK_CONTROL | MASK_SHIFT, C::ChangeAllOccurrences); map.bind(KEY_F2, MASK_CONTROL, C::ChangeAllOccurrences); + // As Visual Studio Code has them: Command and Option on a Mac. + map.bind(KEY_UP, MASK_CONTROL | MASK_ALT, C::AddCaretAbove); + map.bind(KEY_DOWN, MASK_CONTROL | MASK_ALT, C::AddCaretBelow); #if LL_DARWIN // The Mac's own, where MASK_CONTROL is Command: Command with an arrow // goes to the ends of the line and of the text, Option with one by diff --git a/indra/llui/alkeymap.h b/indra/llui/alkeymap.h index c0a68d9407..2e9459ba5f 100644 --- a/indra/llui/alkeymap.h +++ b/indra/llui/alkeymap.h @@ -123,9 +123,14 @@ enum class ALEditorCommand : U8 // The selection grown to what holds it as code reads it, and back. ExpandSelection, ShrinkSelection, - // Every place of a text changed at once: the next place taken, or all. + // Every place of a text changed at once: the next place taken, or all, + // each a selection of its own. SelectNextOccurrence, ChangeAllOccurrences, + // A caret more on the row above each caret, or below it: a column of + // them grown by a row. + AddCaretAbove, + AddCaretBelow, COUNT }; @@ -160,7 +165,8 @@ class ALKeymap // own: control-slash comments, alt-up and alt-down move lines, // control-shift-D duplicates one and control-shift-K deletes one, // control-shift with a square bracket folds and unfolds, - // control-space completes, F12 goes to a definition, shift-F12 finds + // control-space completes, control-alt with up or down adds a caret + // above or below, F12 goes to a definition, shift-F12 finds // the references, F2 renames, control-F finds, control-H (and on a // Mac, which keeps command-H, command-option-F) replaces, and F3 and // shift-F3 go to the next and the previous match. Backspace and Return diff --git a/indra/llui/altextview.cpp b/indra/llui/altextview.cpp index 855537e8bd..6e3ac5c17f 100644 --- a/indra/llui/altextview.cpp +++ b/indra/llui/altextview.cpp @@ -175,6 +175,8 @@ namespace case ALEditorCommand::ShrinkSelection: case ALEditorCommand::SelectNextOccurrence: case ALEditorCommand::ChangeAllOccurrences: + case ALEditorCommand::AddCaretAbove: + case ALEditorCommand::AddCaretBelow: case ALEditorCommand::FindNext: case ALEditorCommand::FindPrevious: case ALEditorCommand::COUNT: @@ -1004,6 +1006,107 @@ bool ALTextView::singleSelection() return true; } +void ALTextView::toggleCaret(const ALTextPos& pos) +{ + const ALTextPos at = mDocument.clamp(pos); + size_t main = 0; + std::vector all = selectionsInOrder(&main); + // The one there -- a caret at it, or a selection over it -- taken + // away, but for the last; the main one, the one before it then. + for (size_t i = 0; i < all.size(); ++i) + { + const ALTextRange range = all[i].normalised(); + if (range.begin <= at && at <= range.end) + { + if (all.size() == 1) + { + return; + } + all.erase(all.begin() + static_cast(i)); + placeSelections(all, main == i ? (i > 0 ? i - 1 : 0) : (main > i ? main - 1 : main)); + return; + } + } + all.emplace_back(at, at); + placeSelections(all, all.size() - 1); +} + +bool ALTextView::addCarets(S32 direction) +{ + size_t main = 0; + const std::vector all = selectionsInOrder(&main); + // Each caret's x where a motion or this left them, else where it is: + // a column grown past a short line keeps to its column. + std::vector xs = mEachDesired.selections == all ? mEachDesired.xs : std::vector(all.size(), -1.f); + std::vector placed = all; + for (size_t i = 0; i < all.size(); ++i) + { + F32 x = xs[i]; + const ALTextPos to = rowsFrom(all[i].end, direction, x); + xs[i] = x >= 0.f ? x : xs[i]; + // Past the first or the last row there is nothing to add. + if (x >= 0.f) + { + placed.emplace_back(to, to); + xs.push_back(x); + } + } + if (placed.size() == all.size()) + { + return false; + } + // The furthest that way the main one, for the view to follow. + size_t furthest = 0; + for (size_t i = 1; i < placed.size(); ++i) + { + if (direction < 0 ? placed[i].end < placed[furthest].end : placed[furthest].end < placed[i].end) + { + furthest = i; + } + } + placeSelections(placed, furthest); + // Each keeps its x, those that met another the first's. + std::vector now = selectionsInOrder(); + std::vector now_xs(now.size(), -1.f); + for (size_t i = 0; i < now.size(); ++i) + { + for (size_t k = 0; k < placed.size(); ++k) + { + if (placed[k].end == now[i].end) + { + now_xs[i] = xs[k]; + break; + } + } + } + mEachDesired.selections = std::move(now); + mEachDesired.xs = std::move(now_xs); + mDesiredX = xs[furthest]; + scrollToCaret(); + return true; +} + +S32 ALTextView::selectAllMatches() +{ + settleFind(); + const std::vector& matches = mFind.matches(); + if (matches.empty()) + { + return 0; + } + // Every match selected, the current one the main one, and the + // keyboard to the text to type over them. + const S32 current = llclamp(mFind.current(), 0, static_cast(matches.size()) - 1); + placeSelections(std::vector(matches.begin(), matches.end()), static_cast(current)); + mDesiredX = -1.f; + scrollToCaret(); + if (mTakesFocus) + { + setFocus(true); + } + return static_cast(matches.size()); +} + std::string ALTextView::wordBeforeCaret() const { const std::string& line = mDocument.line(mCaret.line); @@ -2281,11 +2384,13 @@ bool ALTextView::replaceAll(std::vector> edi void ALTextView::goTo(const ALTextPos& pos) { + singleSelection(); setCaret(mDocument.clamp(pos)); } void ALTextView::goTo(const ALTextRange& range) { + singleSelection(); setSelection(ALTextRange(mDocument.clamp(range.begin), mDocument.clamp(range.end))); } @@ -2738,11 +2843,17 @@ std::optional ALTextView::performAtEach(ALEditorCommand command) scrollToCaret(); return true; } - // Done as they always are: the clipboard's and the comment's know of - // the others; select all, undo and redo put them as they put the - // main one; folding and the find bar shown leave them be. + // Done as they always are: those that add carets, the clipboard's, + // the comment's and completion know of the others; select all, undo + // and redo put them as they put the main one; folding and the find + // bar shown leave them be. case C::None: case C::COUNT: + case C::AddCaretAbove: + case C::AddCaretBelow: + case C::SelectNextOccurrence: + case C::ChangeAllOccurrences: + case C::Complete: case C::ToggleComment: case C::Cut: case C::Copy: @@ -3154,6 +3265,9 @@ bool ALTextView::perform(ALEditorCommand command) case C::NextMisspelling: case C::PreviousMisspelling: return goToMisspelling(command == C::NextMisspelling); + case C::AddCaretAbove: + case C::AddCaretBelow: + return !mReadOnly && addCarets(command == C::AddCaretAbove ? -1 : 1); case C::Find: showFind(false); return true; @@ -3211,6 +3325,10 @@ bool ALTextView::canPerform(ALEditorCommand command) const case C::SelectNextOccurrence: case C::ChangeAllOccurrences: return mFeatures && mFeatures->canPerformFeature(command); + case C::AddCaretAbove: + case C::AddCaretBelow: + // A read-only text shows no caret to add to. + return !mReadOnly; case C::PreviousChange: return mChangeAt > 0; case C::NextChange: @@ -3901,6 +4019,7 @@ void ALTextView::showFind(bool with_replace) mFindBar->onPrevious([this]() { findNext(false); }); mFindBar->onReplace([this]() { replaceMatch(); }); mFindBar->onReplaceAll([this]() { replaceAllMatches(); }); + mFindBar->onSelectAll([this]() { selectAllMatches(); }); mFindBar->onClose([this]() { hideFind(); }); } // Seeded with what is selected, when that is a line's worth or less, @@ -5108,6 +5227,11 @@ bool ALTextView::handleKeyHere(KEY key, MASK mask) // the keyboard away, out into the world, where the arrows walk. if (key == KEY_ESCAPE && mask == MASK_NONE) { + // Back to one caret first, its selection kept; then that let go. + if (singleSelection()) + { + return true; + } const bool selected = hasSelection(); if (selected) { @@ -5211,6 +5335,19 @@ bool ALTextView::handleMouseDown(S32 x, S32 y, MASK mask) return true; } mTripleClick.stop(); + // Alt-click -- Option on a Mac -- a caret more, or the one there taken + // away; any other click one caret again. + if (mask == MASK_ALT && !mReadOnly) + { + toggleCaret(posAtLocal(x, y, true)); + mDesiredX = -1.f; + if (mModal) + { + mModal->mouseChanged(*this); + } + return true; + } + singleSelection(); // A link or an atom under the press is followed on the release, if // the release is on it too. mPressedLink = -1; @@ -5262,7 +5399,8 @@ bool ALTextView::handleMiddleMouseDown(S32 x, S32 y, MASK mask) } // Where the press is, leaving what is selected in place rather than // replacing it, as X11 does: the selection goes, the text it had stays - // the primary one. + // the primary one; and one caret. + singleSelection(); placeCaret(posAtLocal(x, y, true), false); mDesiredX = -1.f; mUndo.beginGroup(); diff --git a/indra/llui/altextview.h b/indra/llui/altextview.h index ff0a66b7c7..310df6ed36 100644 --- a/indra/llui/altextview.h +++ b/indra/llui/altextview.h @@ -365,6 +365,17 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec void setSelections(const ALTextRange& main, std::vector others); // The main selection alone again; whether there were others. bool singleSelection(); + // A caret put at a place as the main one, the others kept -- or, where + // a caret or a selection is there, that one taken away, unless it is + // the last: what Alt-click does. + void toggleCaret(const ALTextPos& pos); + // A caret more on the row above each caret, or below, each keeping + // its column as a motion between rows does; the furthest the main + // one. False where every caret is on the first or the last row. + bool addCarets(S32 direction); + // Every match the find bar has selected, the current one the main + // one, and the keyboard to the text; how many. + S32 selectAllMatches(); // --- editing, through the undo journal ----------------------------------- @@ -397,7 +408,7 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec bool typingText() const { return !mModal || mModal->inserting(); } // The caret put at a place, or a stretch selected, and brought into - // view: where a list of places sends it. + // view, one caret: where a list of places sends it. void goTo(const ALTextPos& pos); void goTo(const ALTextRange& range); diff --git a/indra/llui/tests/alcodeeditor_test.cpp b/indra/llui/tests/alcodeeditor_test.cpp index a7e401060d..ed2620bedf 100644 --- a/indra/llui/tests/alcodeeditor_test.cpp +++ b/indra/llui/tests/alcodeeditor_test.cpp @@ -2305,7 +2305,7 @@ namespace tut template<> template<> void alcodeeditor_object::test<65>() { - set_test_name("Select Next Occurrence takes the name, then its places in turn, going round; what is typed goes into every place taken, one step; Change All takes every place"); + set_test_name("Select Next Occurrence takes the name, then its places in turn, going round, each a selection; what is typed goes into every place taken, one step; Change All selects every place"); ALCodeEditor& e = make("local x = 1\nx = x + xy\nprint(x)", "slua"); e.setAutoComplete(false); e.setAutoClose(false); @@ -2313,29 +2313,31 @@ namespace tut key('D', MASK_CONTROL); ensure_equals("the name", e.selectedText(), std::string("x")); key('D', MASK_CONTROL); - ensure("the next place taken, the selection kept", e.selection().normalised() == ALTextRange(ALTextPos(1, 0), ALTextPos(1, 1))); + ensure("the next place taken, the main selection now", e.selection().normalised() == ALTextRange(ALTextPos(1, 4), ALTextPos(1, 5))); + ensure("the first kept beside it", e.otherSelections().size() == 1 && e.otherSelections()[0] == ALTextRange(ALTextPos(1, 0), ALTextPos(1, 1))); type("count"); ensure_equals("typed into both, not into the longer name", e.text(), std::string("local x = 1\ncount = count + xy\nprint(x)")); e.undo(); ensure_equals("one step back takes back both", e.text(), std::string("local x = 1\nx = x + xy\nprint(x)")); - ensure("and ends it", e.placeholders().empty()); e.setCaret(ALTextPos(2, 6)); + e.singleSelection(); key('D', MASK_CONTROL); key('D', MASK_CONTROL); key('D', MASK_CONTROL); type("n"); ensure_equals("going round from the last to the first", e.text(), std::string("local n = 1\nn = x + xy\nprint(n)")); key(KEY_ESCAPE); - ensure("Escape lets go", e.placeholders().empty()); + ensure("Escape lets go", !e.hasOtherSelections()); e.setText("local x = 1\nx = x + xy\nprint(\"x\")"); e.setCaret(ALTextPos(0, 6)); key('L', MASK_CONTROL | MASK_SHIFT); + ensure_equals("Change All: every place selected", e.otherSelections().size(), static_cast(3)); type("n"); - ensure_equals("Change All: every place of the name, as written", e.text(), std::string("local n = 1\nn = n + xy\nprint(\"n\")")); - e.setCaret(ALTextPos(2, 0)); - ensure("the caret off the line: let go", e.placeholders().empty()); + ensure_equals("every place of the name, as written", e.text(), std::string("local n = 1\nn = n + xy\nprint(\"n\")")); + e.goTo(ALTextPos(2, 0)); + ensure("gone elsewhere: one caret", !e.hasOtherSelections()); type("-- "); ensure_equals("and typing is typing again", e.document().line(2), std::string("-- print(\"n\")")); } @@ -2644,4 +2646,41 @@ namespace tut type("("); ensure_equals("closed where it is code, typed as ever in the comment", e.text(), std::string("x()\n// y(")); } + + template<> template<> + void alcodeeditor_object::test<74>() + { + set_test_name("Select Next Occurrence at several carets takes the word at each, then the next place as the main one; a completion accepted goes in at each caret, its first parameter chosen at each"); + ALCodeEditor& e = make("one\none two\none", "slua"); + e.setAutoComplete(false); + e.setAutoClose(false); + const auto at = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + e.setSelections(at(0, 1), { at(2, 1) }); + key('D', MASK_CONTROL); + ensure("the word at the main caret", e.selection() == ALTextRange(ALTextPos(0, 0), ALTextPos(0, 3))); + ensure("and at the other", e.otherSelections().size() == 1 && e.otherSelections()[0] == ALTextRange(ALTextPos(2, 0), ALTextPos(2, 3))); + key('D', MASK_CONTROL); + ensure("the next place not taken, the main one", e.selection() == ALTextRange(ALTextPos(1, 0), ALTextPos(1, 3)) && e.otherSelections().size() == 2); + type("1"); + ensure_equals("typed at each", e.text(), std::string("1\n1 two\n1")); + + e.setText("myC\n myC"); + e.setCompletionProvider([](const ALTextPos&, std::string_view, std::vector& out) { + ALCodeEditor::Completion c; + c.text = "myCall"; + c.kind = ALSyntaxKind::Function; + c.detail = "myCall(integer channel, string msg)"; + out.push_back(c); + }); + e.setSelections(at(0, 3), { at(1, 5) }); + key(' ', MASK_CONTROL); + ensure("the list at the main caret, the other kept", e.completionOpen() && e.hasOtherSelections()); + ensure("the call offered", !e.completions().empty() && e.completions()[0].text == "myCall"); + key(KEY_RETURN); + ensure_equals("in at each, over what was typed", e.text(), std::string("myCall(channel, msg)\n myCall(channel, msg)")); + ensure("the first parameter chosen at the main caret", e.selection() == ALTextRange(ALTextPos(0, 7), ALTextPos(0, 14))); + ensure("and at the other", e.otherSelections().size() == 1 && e.otherSelections()[0] == ALTextRange(ALTextPos(1, 9), ALTextPos(1, 16))); + e.undo(); + ensure_equals("one step", e.text(), std::string("myC\n myC")); + } } diff --git a/indra/llui/tests/altextview_test.cpp b/indra/llui/tests/altextview_test.cpp index a35c1871da..622e7e580d 100644 --- a/indra/llui/tests/altextview_test.cpp +++ b/indra/llui/tests/altextview_test.cpp @@ -2183,4 +2183,65 @@ namespace tut v.selectAll(); ensure("select all takes them in", !v.hasOtherSelections() && v.selection().normalised() == ALTextRange(ALTextPos(0, 0), ALTextPos(2, 5))); } + + template<> template<> + void altextview_object::test<70>() + { + set_test_name("Alt-click adds a caret or takes the one there away, any other click is one caret; Escape is one caret before it lets the selection go; Control-Alt with up and down adds carets, each keeping its column"); + ALTextView& v = make("hello world\nhi\nhello world\nx\nhello world"); + const auto click = [&](S32 line, S32 column, MASK mask) { + S32 x = 0, y = 0; + pointOf(line, column, x, y); + v.handleMouseDown(x, y, mask); + v.handleMouseUp(x, y, mask); + }; + click(0, 3, MASK_NONE); + click(2, 3, MASK_ALT); + ensure("the new caret the main one", v.selection() == caretRange(2, 3)); + ensure_equals("the first beside it", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 3) })); + click(2, 3, MASK_ALT); + ensure("taken away again, the one before it main", v.selection() == caretRange(0, 3) && !v.hasOtherSelections()); + click(0, 3, MASK_ALT); + ensure("the last is not taken away", v.selection() == caretRange(0, 3)); + click(4, 5, MASK_ALT); + click(1, 1, MASK_NONE); + ensure("a plain click: one caret", v.selection() == caretRange(1, 1) && !v.hasOtherSelections()); + + v.setSelections(ALTextRange(ALTextPos(0, 0), ALTextPos(0, 5)), { caretRange(2, 0) }); + key(KEY_ESCAPE); + ensure("Escape: one caret, its selection kept", !v.hasOtherSelections() && v.hasSelection()); + key(KEY_ESCAPE); + ensure("and then the selection let go", !v.hasSelection()); + + v.setCaret(ALTextPos(0, 8)); + key(KEY_DOWN, MASK_CONTROL | MASK_ALT); + ensure("a caret below, at the short line's end, the main one", v.selection() == caretRange(1, 2)); + key(KEY_DOWN, MASK_CONTROL | MASK_ALT); + ensure("and below that, back in its column", v.selection() == caretRange(2, 8)); + ensure_equals("each kept", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 8), caretRange(1, 2) })); + type("!"); + ensure_equals("typed at each", v.text(), std::string("hello wo!rld\nhi!\nhello wo!rld\nx\nhello world")); + v.setCaret(ALTextPos(4, 2)); + v.singleSelection(); + ensure("nothing below the last line", !v.perform(ALEditorCommand::AddCaretBelow)); + key(KEY_UP, MASK_CONTROL | MASK_ALT); + ensure("one above it", v.selection() == caretRange(3, 1) && v.otherSelections().size() == 1); + v.setReadOnly(true); + ensure("none in a read-only text", !v.perform(ALEditorCommand::AddCaretAbove) && !v.canPerform(ALEditorCommand::AddCaretAbove)); + } + + template<> template<> + void altextview_object::test<71>() + { + set_test_name("Alt-Return in the find bar selects every match, each a selection, the current one the main one, the keyboard back on the text"); + ALTextView& v = make("cat dog cat bird cat"); + v.showFind(false); + v.findBar()->setQuery("cat"); + ensure("three found", v.findMatches().size() == 3); + ensure("taken", v.findBar()->handleKeyHere(KEY_RETURN, MASK_ALT)); + ensure_equals("each a selection", ll_test::TextViewProbe::selections(v).size(), static_cast(3)); + ensure("the keyboard on the text", v.keyboardOnText()); + type("cow"); + ensure_equals("typed over each", v.text(), std::string("cow dog cow bird cow")); + } } diff --git a/indra/newview/alfloaterscriptstudio.cpp b/indra/newview/alfloaterscriptstudio.cpp index fd80784daf..24269afdb6 100644 --- a/indra/newview/alfloaterscriptstudio.cpp +++ b/indra/newview/alfloaterscriptstudio.cpp @@ -8130,6 +8130,8 @@ void ALFloaterScriptStudio::addEditCommands() addEditorCommand("shrink_selection", ALEditorCommand::ShrinkSelection, false); addEditorCommand("select_next_occurrence", ALEditorCommand::SelectNextOccurrence, true); addEditorCommand("change_all_occurrences", ALEditorCommand::ChangeAllOccurrences, true); + addEditorCommand("add_caret_above", ALEditorCommand::AddCaretAbove, false); + addEditorCommand("add_caret_below", ALEditorCommand::AddCaretBelow, false); mCommands.add( "convert_slua", [this]() { diff --git a/indra/newview/skins/default/xui/en/floater_script_studio.xml b/indra/newview/skins/default/xui/en/floater_script_studio.xml index b7c2cd7607..f73d7165c2 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio.xml @@ -974,6 +974,15 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is + + + + + + + + + Shrink the selection Select next occurrence Change all occurrences + Add a caret above + Add a caret below Previous misspelling Date: Sat, 3 Oct 2026 14:49:00 -0400 Subject: [PATCH 04/41] SLua's forin snippet names its loop's variables k and v As Luau's documentation of generalized iteration names them, and as SLua scripts mostly write them. Co-Authored-By: Claude Opus 5.5 --- indra/newview/app_settings/snippets/slua.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/indra/newview/app_settings/snippets/slua.xml b/indra/newview/app_settings/snippets/slua.xml index a39cb241b7..9db891ddb8 100644 --- a/indra/newview/app_settings/snippets/slua.xml +++ b/indra/newview/app_settings/snippets/slua.xml @@ -96,7 +96,7 @@ end detail A loop over a table body - for ${1:key}, ${2:value} in ${3:table} do + for ${1:k}, ${2:v} in ${3:table} do $0 end From dc9934ceb7d48a499b48a7d6ba5b65c30a70c9e9 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 14:49:00 -0400 Subject: [PATCH 05/41] A function made global at the top is a lint of its own; a for's variable set in it is a lint SlGlobalFunction says what SlGlobalAssign said of function f() at the top of a script, so that it can be turned off alone: a script moved from LSL keeps its functions global while they still call functions written after them, which a local function cannot be called before. SlGlobalAssign keeps the globals given a value at the top and the functions made global inside a block. SlForIndexAssign warns where a numeric for's variable is set in its body, plainly or by +=: LSL's for went on from what it was set to, where Luau's gives it its own next value each time round, so a loop moved from LSL that sets it to go back or to skip one does neither. A while loop does; there is no fix, the loop being the scripter's to rewrite. Both rules are added at the end of the table, so that the lint masks already kept read as they did. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/alscriptlintpass.cpp | 88 +++++++++++++++++-- .../alscript/tests/alscriptlintpass_test.cpp | 78 ++++++++++++---- .../xui/en/floater_script_studio_prefs.xml | 4 +- .../newview/skins/default/xui/en/strings.xml | 1 + 4 files changed, 148 insertions(+), 23 deletions(-) diff --git a/indra/alscript/alscriptlintpass.cpp b/indra/alscript/alscriptlintpass.cpp index c252107958..127d7d7d67 100644 --- a/indra/alscript/alscriptlintpass.cpp +++ b/indra/alscript/alscriptlintpass.cpp @@ -70,8 +70,9 @@ namespace { "SlCompatCall", Rule::SLua, Severity::Note, true, true, nullptr, true }, // b == 1, where b is a boolean: LSL's truths were numbers. { "SlBooleanNumber", Rule::SLua, Severity::Error, true, true, nullptr, true }, - // x = 0 or function f() making a global; a function f() in a nested - // scope, lute's global_function_in_scope. + // x = 0 making a global at the top; a function f() in a nested scope, + // lute's global_function_in_scope. A function f() at the top is + // SlGlobalFunction's. { "SlGlobalAssign", Rule::SLua, Severity::Warning, true, true, nullptr, true }, // if (x) then: LSL's brackets, which Luau's if needs none of. { "SlParenCondition", Rule::SLua, Severity::Note, true, true, "parenthese_conditions", true }, @@ -104,6 +105,13 @@ namespace { "SlStringBuild", Rule::Both, Severity::Note, true, true, nullptr }, // llGetOwner() asked again and again in one handler. { "SlRepeatedCall", Rule::Both, Severity::Note, true, true, nullptr }, + // function f() at the top making a global: a rule of its own, which a + // script moved from LSL turns off while its functions still call + // those written after them, which a local function cannot be. + { "SlGlobalFunction", Rule::SLua, Severity::Note, true, true, nullptr, true }, + // for i = 1, n do ... i = j ... end: LSL's for went on from what i was + // set to, Luau's makes i afresh each time round. + { "SlForIndexAssign", Rule::SLua, Severity::Warning, true, false, nullptr, true }, }; const ALScriptLintPass::PrimParams PRIM_PARAMS[] = { @@ -427,6 +435,41 @@ namespace Luau::AstLocal* mLocal; }; + // Where a local is given a value in a stretch of the tree, plainly or + // by x += y: each place it is set. + class Sets final : public Luau::AstVisitor + { + public: + explicit Sets(Luau::AstLocal* local) : mLocal(local) {} + + std::vector at; + + bool visit(Luau::AstStatAssign* node) override + { + for (Luau::AstExpr* var : node->vars) + { + set(var); + } + return true; + } + bool visit(Luau::AstStatCompoundAssign* node) override + { + set(node->var); + return true; + } + + private: + void set(Luau::AstExpr* var) + { + if (const auto* local = var->as(); local && local->local == mLocal) + { + at.push_back(var->location); + } + } + + Luau::AstLocal* mLocal; + }; + // Every place each global is named, in the order of the text, and // whether the script names _G, through which any global may be read. class Globals final : public Luau::AstVisitor @@ -602,11 +645,18 @@ namespace bool visit(Luau::AstStatBlock* node) override { - if (on("SlGlobalAssign")) + if (on("SlGlobalAssign") || on("SlGlobalFunction")) { for (Luau::AstStat* stat : node->body) { - node == mRoot ? topGlobal(stat) : nestedFunction(node, stat); + if (node == mRoot) + { + topGlobal(stat); + } + else if (on("SlGlobalAssign")) + { + nestedFunction(node, stat); + } } } if (on("SlAlmostSwapped")) @@ -1078,6 +1128,10 @@ namespace { zeroLoop(node); } + if (on("SlForIndexAssign")) + { + forIndexSet(node); + } mOften.push_back(node->body->location); emptyLoop(node->body, Luau::Location(node->location.begin, node->body->location.begin), Luau::Location(node->body->location.begin, node->location.end), "for"); @@ -1983,6 +2037,10 @@ namespace { std::vector made_here; const bool function = stat->is(); + if (!on(function ? "SlGlobalFunction" : "SlGlobalAssign")) + { + return; + } if (const auto* assign = stat->as()) { for (Luau::AstExpr* var : assign->vars) @@ -2022,7 +2080,7 @@ namespace } ALScriptProblem& said = function ? problem(stat->location, "LuauLintSlGlobalFunction", "function [1] makes a global. local function [1] is quicker for Luau to call", { names }, - "SlGlobalAssign", Severity::Note) + "SlGlobalFunction", Severity::Note) : problem(stat->location, "LuauLintSlGlobalAssign", "This makes [1] a global. A local is quicker for Luau to read", { names }, "SlGlobalAssign", Severity::Note); @@ -2085,6 +2143,26 @@ namespace } } + // --- SlForIndexAssign: a numeric for's variable set inside it ------- + + // LSL's for went on from whatever its variable was set to in the + // body -- i = start to go back, i++ to skip one. Luau's numeric for + // makes the variable afresh from its own count each time round, so + // what the body gives it lasts to the end of that time round only. + // No fix: what the loop should do instead is a while loop's to say. + void forIndexSet(Luau::AstStatFor* node) + { + Sets sets(node->var); + node->body->visit(&sets); + for (const Luau::Location& at : sets.at) + { + problem(at, "LuauLintSlForIndexAssign", + "[1] is set here, but the for gives [1] its own next value each time round, whatever it was set to: LSL's for went on " + "from it. A while loop does", + { std::string(node->var->name.value) }, "SlForIndexAssign"); + } + } + // --- SlParenCondition: LSL's brackets round a condition ------------- // A condition bracketed whole, as LSL's had to be: the brackets taken diff --git a/indra/alscript/tests/alscriptlintpass_test.cpp b/indra/alscript/tests/alscriptlintpass_test.cpp index 5621f052d2..a9634aa8dd 100644 --- a/indra/alscript/tests/alscriptlintpass_test.cpp +++ b/indra/alscript/tests/alscriptlintpass_test.cpp @@ -301,30 +301,41 @@ namespace tut template<> template<> void object::test<5>() { - set_test_name("SlGlobalAssign: a global made at the top, once, a note; a function made global in a block, a warning; not a name the " - "script is given; each made local, in place or declared before it is first named, and not where _G is"); + set_test_name("SlGlobalAssign: a global made at the top, once, a note; a function made global in a block, a warning; a function " + "at the top SlGlobalFunction's, a note, off on its own; not a name the script is given; each made local, in place or " + "declared before it is first named, and not where _G is"); ensure("definitions: " + error, loaded); - const std::string said = found("count = 0\n" - "function bump() count += 1; total = count end\n" - "local function early() return later end\n" - "later = 5\n" - "print = print\n" - "local function outer()\n" - " function inner() return 1 end\n" - " function shared() return 2 end\n" - " return inner()\n" - "end\n" - "x, y = 1, 2\n" - "print(bump, early, outer, shared, total, x, y)\n" - "count = 1\n", - "SlGlobalAssign", ALScriptProblem::Severity::Warning); + const std::string script = "count = 0\n" + "function bump() count += 1; total = count end\n" + "local function early() return later end\n" + "later = 5\n" + "print = print\n" + "local function outer()\n" + " function inner() return 1 end\n" + " function shared() return 2 end\n" + " return inner()\n" + "end\n" + "x, y = 1, 2\n" + "print(bump, early, outer, shared, total, x, y)\n" + "count = 1\n"; + const std::string said = found(script, "SlGlobalAssign", ALScriptProblem::Severity::Warning); ensure_equals("each", said, std::string("0 LuauLintSlGlobalAssign|count (another severity)\n" - "1 LuauLintSlGlobalFunction|bump (another severity)\n" "3 LuauLintSlGlobalAssign|later (another severity)\n" "6 LuauLintSlGlobalFunctionInScope|inner\n" "7 LuauLintSlGlobalFunctionInScope|shared\n" "10 LuauLintSlGlobalAssign|x, y (another severity)\n")); + ensure_equals("the function at the top", found(script, "SlGlobalFunction", ALScriptProblem::Severity::Note), + std::string("1 LuauLintSlGlobalFunction|bump\n")); + ensure_equals("off on its own: the rest still said", found("--!nolint SlGlobalFunction\n" + script, "SlGlobalAssign", ALScriptProblem::Severity::Warning), + std::string("1 LuauLintSlGlobalAssign|count (another severity)\n" + "4 LuauLintSlGlobalAssign|later (another severity)\n" + "7 LuauLintSlGlobalFunctionInScope|inner\n" + "8 LuauLintSlGlobalFunctionInScope|shared\n" + "11 LuauLintSlGlobalAssign|x, y (another severity)\n")); + ensure("and it not", found("--!nolint SlGlobalFunction\n" + script, "SlGlobalFunction", ALScriptProblem::Severity::Note).empty()); + ensure("nor the function where SlGlobalAssign alone is off", + found("--!nolint SlGlobalAssign\n" + script, "SlGlobalFunction", ALScriptProblem::Severity::Note) == "2 LuauLintSlGlobalFunction|bump\n"); ensure_equals("in place", fixed("count = 0\nprint(count)\n", "LuauLintSlGlobalAssign", "Write it local count", false), std::string("local count = 0\nprint(count)\n")); ensure_equals("declared first", fixed("local function early() return later end\nlater = 5\nprint(early())\n", "LuauLintSlGlobalAssign", @@ -828,4 +839,37 @@ namespace tut note.key = "SluaNote"; ensure("a note", ALScriptLintPass::migration(note)); } + + template<> template<> + void object::test<22>() + { + set_test_name("SlForIndexAssign: a numeric for's variable set in its body, plainly or by +=, a warning at each, in a function it makes " + "too; not a local of the same name in it, not a generic for's, not a while's; no fix"); + ensure("definitions: " + error, loaded); + const std::string said = found("local list = {\"a\", \"a\", \"b\"}\n" + "local start = 1\n" + "for i = 1, #list - 1 do\n" + " if list[i] ~= list[start] then\n" + " start += 1\n" + " i = start\n" + " end\n" + " i += 1\n" + " local function later() i = 0 end\n" + " later()\n" + "end\n" + "for i = 1, 2 do\n" + " local i = 5\n" + " i = 6\n" + " print(i)\n" + "end\n" + "for k, v in list do k = 2; print(k, v) end\n" + "local n = 1\n" + "while n < 3 do n = n + 1 end\n", + "SlForIndexAssign", ALScriptProblem::Severity::Warning); + ensure_equals("each", said, + std::string("5 LuauLintSlForIndexAssign|i\n" + "7 LuauLintSlForIndexAssign|i\n" + "8 LuauLintSlForIndexAssign|i\n")); + unfixed("for i = 1, 3 do i = 2 end\n", "LuauLintSlForIndexAssign"); + } } diff --git a/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml b/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml index 62a7936090..e44327f2ba 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml @@ -103,7 +103,9 @@ A list, a string or ll counted from 0, as LSL counted (the studio's own) llcompat's function where ll's means the same (the studio's own) A boolean compared with a number, as LSL's TRUE and FALSE were (the studio's own) - A global made where a local would do, or a function made global inside a block (the studio's own) + A global given a value at the top where a local would do, or a function made global inside a block (the studio's own) + A for loop's own variable set inside it, which Luau's for does not go on from as LSL's did (the studio's own) + A function at the top made global where a local function would do (the studio's own) A condition in brackets, as LSL's had to be (the studio's own) a = b and then b = a, which is no swap (the studio's own) A call that only answers, its answer thrown away (the studio's own) diff --git a/indra/newview/skins/default/xui/en/strings.xml b/indra/newview/skins/default/xui/en/strings.xml index a53b303a75..112ace8254 100644 --- a/indra/newview/skins/default/xui/en/strings.xml +++ b/indra/newview/skins/default/xui/en/strings.xml @@ -898,6 +898,7 @@ If you feel this is an error, please contact support@secondlife.com [1] asks no more than [2]: its 1 or 0 is made only to be compared This makes [1] a global. A local is quicker for Luau to read function [1] makes a global. local function [1] is quicker for Luau to call + [1] is set here, but the for gives [1] its own next value each time round, whatever it was set to: LSL's for went on from it. A while loop does function [1] here makes a global, and only once this runs. local function [1] keeps it to this block Luau's [1] needs no brackets round its condition, where LSL's did [1] = [2] and then [2] = [1] leave both [2]: a swap is [3] From 48d3eafb282d8e011b9160c6a292ee39adecb7c6 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 14:49:00 -0400 Subject: [PATCH 06/41] Apply on previewed fixes says why where it makes none Apply with nothing previewed waiting, with the problems the fixes were for gone since -- the script then checked again -- or with the text refusing the edits, made nothing and said nothing. Each now says so in the status line. A test follows what is left from LSL from its preview to Apply. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alscriptstudiochecking.cpp | 21 ++++++++++++++-- .../default/xui/en/floater_script_studio.xml | 2 ++ .../tests/alscriptstudiochecking_test.cpp | 25 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/indra/newview/alscriptstudiochecking.cpp b/indra/newview/alscriptstudiochecking.cpp index c0218303af..1593036c44 100644 --- a/indra/newview/alscriptstudiochecking.cpp +++ b/indra/newview/alscriptstudiochecking.cpp @@ -1562,8 +1562,10 @@ void ALScriptStudioChecking::previewFixAll(Doc& doc, const FixPick& pick) bool ALScriptStudioChecking::applyPreviewed(Doc& doc) { + // Never nothing said: Apply that makes nothing says why. if (!doc.check->fixAllPreviewed) { + mServices.setStatus(mServices.words("FixAllNotPreviewed"), true); return false; } const Doc::Check::FixAllPreview previewed = *doc.check->fixAllPreviewed; @@ -1599,9 +1601,24 @@ bool ALScriptStudioChecking::fixAll(Doc& doc, const FixPick& pick) } } // Every one of them one step to undo: they were made over one check, - // and none meets another. - if (edits.empty() || !source.replaceAll(std::move(edits))) + // and none meets another. Asked for and none made, said: the problems + // they were for gone since, which a check says again, or the text + // refusing them. + if (edits.empty()) { + if (!pick.forSave) + { + mServices.setStatus(mServices.words("FixStale"), true); + schedule(doc, true); + } + return false; + } + if (!source.replaceAll(std::move(edits))) + { + if (!pick.forSave) + { + mServices.setStatus(mServices.words("FixAllNotMade"), true); + } return false; } source.undoJournal().label("fix"); diff --git a/indra/newview/skins/default/xui/en/floater_script_studio.xml b/indra/newview/skins/default/xui/en/floater_script_studio.xml index f73d7165c2..ebe04e13d5 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio.xml @@ -469,6 +469,8 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is Renamed to [NAME] [SCRIPT] is not open here, or has changed since the lookup; its occurrences are listed in References. [FIXES] for [NAME], shown beside the text. Apply makes them, as one step to undo. + Nothing previewed is waiting to be applied. Ask for the fixes again to see them. + The fixes were not made: the text would not take them. The text changed after the preview, so its fixes were not made. Use Fix All again to see them for the text now. Apply Make the fixes previewed in [NAME], as one step to undo diff --git a/indra/newview/tests/alscriptstudiochecking_test.cpp b/indra/newview/tests/alscriptstudiochecking_test.cpp index f751af0b5b..9058faeaf0 100644 --- a/indra/newview/tests/alscriptstudiochecking_test.cpp +++ b/indra/newview/tests/alscriptstudiochecking_test.cpp @@ -1331,4 +1331,29 @@ namespace tut checking.askFixAll(safe, pick); ensure("all safe: asked as Fix All asks", (bool)studio.preview); } + + template<> template<> + void alscriptstudiochecking_object::test<24>() + { + set_test_name("what is left from LSL put right where a fix could change the script: previewed beside the text, and made by Apply"); + ALScriptStudioChecking& checking = make(); + Doc& doc = tab("a", "local t = {}\nif #t then print(1) end\n"); + doc.language.lua = true; + checking.schedule(doc, true); + checking.pump(1.0); + ALScriptProblem truth = problem(1, "#t is a number", ALScriptProblem::Severity::Warning); + truth.source = ALScriptProblem::Source::Lint; + truth.code = "SlNumberTruth"; + truth.fixes = { fix("Compare it: #t ~= 0", 1, 3, 5, "#t ~= 0", false) }; + studio.asks.back().answered(answer(doc, { truth })); + Doc::FixPick pick; + pick.migration = true; + checking.askFixAll(doc, pick); + ensure("previewed, not asked", !studio.preview && studio.told.back().rfind("compare a: ", 0) == 0); + ensure("Apply offered", services.reports.back().actions == Names{ "apply_fixes" }); + ensure("made by Apply", checking.applyPreviewed(doc)); + ensure_equals("put right", doc.editor->document().line(1), std::string("if #t ~= 0 then print(1) end")); + ensure("Apply again: nothing waiting, and said", !checking.applyPreviewed(doc) && services.statuses.back() == "FixAllNotPreviewed" && + services.statusFailures.back()); + } } From 1f8b90930b32c3127a93f4c73af004ff50e868c8 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 15:03:49 -0400 Subject: [PATCH 07/41] Apply on previewed fixes says so where the tab cannot be changed The last way an asked Apply made nothing in silence: a tab no longer loaded or no longer modifiable. A save's own fixes stay quiet, as the save says what it did. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alscriptstudiochecking.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/indra/newview/alscriptstudiochecking.cpp b/indra/newview/alscriptstudiochecking.cpp index 1593036c44..1c79e8f2e5 100644 --- a/indra/newview/alscriptstudiochecking.cpp +++ b/indra/newview/alscriptstudiochecking.cpp @@ -1583,6 +1583,10 @@ bool ALScriptStudioChecking::fixAll(Doc& doc, const FixPick& pick) { if (!doc.loaded || !doc.modifiable) { + if (!pick.forSave) + { + mServices.setStatus(mServices.words("FixAllNotMade"), true); + } return false; } // Only the fixes made over the text as it stands (pickFixes). Made on From d341ff8b89b9229bd31c624720b76b07a95007f7 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 15:03:49 -0400 Subject: [PATCH 08/41] SLua's weights name its handlers and count its strings on their lines Weighed for SLua, a handler LLEvents is given -- LLEvents:on, once, a field of LLEvents -- is a handler named by its event, and one in a state's table, as the assistant writes them, with its state; what LLTimers calls is named by how it is set going. Each part now spans its function to its end, as the parser has it, where it ran only to the last line that made code. The notes over the text and the Weights tab said "unnamed" of every handler written as SLua writes them. A line's bytes now take in the constants it is first to name in its function and the strings it is first to name in the script, read off the bytecode's instructions, so that a long string weighs on the line that says it as it does under LSO. They had been nobody's. LSL on Luau's compiler records no lines at all, so its lines cannot be weighed one by one; the Weights tab says so, rather than showing a heat of nothing. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/alscriptweight.cpp | 364 +++++++++++++++++- indra/alscript/tests/alscriptweight_test.cpp | 56 +++ indra/newview/alscriptweightspane.cpp | 7 + .../default/xui/en/floater_script_studio.xml | 1 + .../tests/alscriptweightspane_test.cpp | 25 ++ 5 files changed, 443 insertions(+), 10 deletions(-) diff --git a/indra/alscript/alscriptweight.cpp b/indra/alscript/alscriptweight.cpp index b2d07dff5c..41088db198 100644 --- a/indra/alscript/alscriptweight.cpp +++ b/indra/alscript/alscriptweight.cpp @@ -31,12 +31,16 @@ #include "alluauservice.h" #include "alsourcemap.h" +#include "Luau/Ast.h" #include "Luau/Bytecode.h" #include "Luau/BytecodeHeader.h" +#include "Luau/BytecodeUtils.h" #include "Luau/Compiler.h" #include "Luau/LSLCompiler.h" #include "Luau/ParseResult.h" +#include "Luau/Parser.h" +#include #include #include #include @@ -196,18 +200,36 @@ namespace bool mOk = true; }; + // An entry of a prototype's constant table: what kind, what it takes + // up there, and the strings it names -- a string's own, an import's + // path, a table template's keys -- by their places in the string + // table, from 0. + struct Constant + { + uint8_t type = 0; + size_t bytes = 0; + std::vector strings; + // What it names by other constants of the same table: an import's + // path, a template's keys, each to be read as a string once all are. + std::vector constants; + }; + struct Proto { - size_t begin = 0; - size_t end = 0; - uint64_t lineDefined = 0; - uint64_t name = 0; // into the string table, from one - std::vector lines; // one-based, per code word; empty without line info + size_t begin = 0; + size_t end = 0; + uint64_t lineDefined = 0; + uint64_t name = 0; // into the string table, from one + std::vector lines; // one-based, per code word; empty without line info + std::vector code; + std::vector constants; }; struct Bytecode { std::vector strings; + // What each string takes up in the table: its length and itself. + std::vector stringBytes; size_t stringsBegin = 0; size_t stringsEnd = 0; std::vector protos; @@ -227,12 +249,14 @@ namespace const uint64_t count = in.varint(); for (uint64_t i = 0; i < count && in.ok(); ++i) { + const size_t entry = in.at(); const uint64_t length = in.varint(); const size_t from = in.at(); in.skip(length); if (in.ok()) { out.strings.emplace_back(data.substr(from, static_cast(length))); + out.stringBytes.push_back(in.at() - entry); } } out.stringsEnd = in.at(); @@ -261,11 +285,17 @@ namespace } } const uint64_t sizecode = in.varint(); - in.skip(sizecode * 4); + for (uint64_t j = 0; j < sizecode && in.ok(); ++j) + { + p.code.push_back(static_cast(in.int32())); + } const uint64_t sizek = in.varint(); for (uint64_t k = 0; k < sizek && in.ok(); ++k) { - switch (in.byte()) + Constant constant; + const size_t entry = in.at(); + constant.type = in.byte(); + switch (constant.type) { case LBC_CONSTANT_NIL: break; @@ -282,18 +312,33 @@ namespace in.skip(32); break; case LBC_CONSTANT_STRING: + // From one; nought is no string. + if (const uint64_t id = in.varint(); id > 0) + { + constant.strings.push_back(id - 1); + } + break; case LBC_CONSTANT_CLOSURE: in.varint(); break; case LBC_CONSTANT_IMPORT: - in.skip(4); + { + // Up to three of the table's strings, ten bits each, + // their count in the top two. + const uint32_t id = static_cast(in.int32()); + const uint32_t names = id >> 30; + for (uint32_t j = 0; j < names; ++j) + { + constant.constants.push_back((id >> (20 - 10 * j)) & 1023); + } break; + } case LBC_CONSTANT_TABLE: { const uint64_t keys = in.varint(); for (uint64_t j = 0; j < keys && in.ok(); ++j) { - in.varint(); + constant.constants.push_back(in.varint()); } break; } @@ -302,7 +347,7 @@ namespace const uint64_t keys = in.varint(); for (uint64_t j = 0; j < keys && in.ok(); ++j) { - in.varint(); + constant.constants.push_back(in.varint()); in.skip(4); } break; @@ -324,6 +369,8 @@ namespace default: return false; } + constant.bytes = in.at() - entry; + p.constants.push_back(std::move(constant)); } const uint64_t children = in.varint(); for (uint64_t c = 0; c < children && in.ok(); ++c) @@ -410,6 +457,59 @@ namespace p.bytes = bytes; return p; } + + // The constants an instruction names, by their places in its + // prototype's table: where each opcode keeps the index, as Bytecode.h + // documents it. + template + void constantsOf(uint32_t insn, uint32_t aux, Use use) + { + switch (static_cast(LUAU_INSN_OP(insn))) + { + case LOP_LOADK: + case LOP_GETIMPORT: + case LOP_DUPTABLE: + case LOP_DUPCLOSURE: + use(static_cast(LUAU_INSN_D(insn))); + break; + case LOP_LOADKX: + case LOP_GETGLOBAL: + case LOP_SETGLOBAL: + case LOP_GETTABLEKS: + case LOP_SETTABLEKS: + case LOP_NAMECALL: + case LOP_FASTCALL2K: + case LOP_NEWCLASSMEMBER: + use(aux); + break; + case LOP_JUMPXEQKN: + case LOP_JUMPXEQKS: + use(LUAU_INSN_AUX_KV(aux)); + break; + case LOP_GETUDATAKS: + case LOP_SETUDATAKS: + case LOP_NAMECALLUDATA: + use(LUAU_INSN_AUX_KV16(aux)); + break; + case LOP_ADDK: + case LOP_SUBK: + case LOP_MULK: + case LOP_DIVK: + case LOP_MODK: + case LOP_POWK: + case LOP_ANDK: + case LOP_ORK: + case LOP_IDIVK: + use(LUAU_INSN_C(insn)); + break; + case LOP_SUBRK: + case LOP_DIVRK: + use(LUAU_INSN_B(insn)); + break; + default: + break; + } + } } namespace @@ -421,6 +521,12 @@ namespace // a function, `_e/` a handler of the state so numbered -- // and where each is in the script. void nameLSLParts(ALScriptWeight& weight, std::string_view source); + // SLua's prototypes where the script has them, from `function` to its + // `end`, and named as the script means them where the compiler gives + // no name or not that one: a handler LLEvents is given, by its event -- + // and its state's, in a table of a state's handlers as the assistant + // writes them -- and what LLTimers calls, by how it is set going. + void nameSLuaParts(ALScriptWeight& weight, std::string_view source); } namespace ALScriptWeigh @@ -447,6 +553,10 @@ namespace ALScriptWeigh return weight; } weighAsset(weight, asset); + if (weight.compiled) + { + nameSLuaParts(weight, source); + } return weight; } @@ -507,6 +617,8 @@ namespace weight.compiled = true; weight.parts.push_back(part(ALScriptWeight::Part::Kind::Constant, "strings", read.stringsEnd - read.stringsBegin)); std::map by_line; + // The first line, from one, that names each string; nought for none. + std::vector string_at(read.strings.size(), 0); for (size_t i = 0; i < read.protos.size(); ++i) { const Proto& p = read.protos[i]; @@ -531,6 +643,53 @@ namespace last = std::max(last, line); } } + // Each constant of the prototype the first line of it that + // names it, and each string the first line of the script. + if (!p.lines.empty()) + { + std::vector constant_at(p.constants.size(), 0); + for (size_t pc = 0; pc < p.code.size();) + { + const uint32_t insn = p.code[pc]; + const uint32_t aux = pc + 1 < p.code.size() ? p.code[pc + 1] : 0; + const S32 line = pc < p.lines.size() ? p.lines[pc] : 0; + constantsOf(insn, aux, [&](uint32_t k) { + if (line > 0 && k < constant_at.size() && (constant_at[k] == 0 || line < constant_at[k])) + { + constant_at[k] = line; + } + }); + pc += static_cast(std::max(1, Luau::getOpLength(static_cast(LUAU_INSN_OP(insn))))); + } + // What an import's path and a template's keys name, with + // the constant that names them: no instruction does. + for (size_t k = 0; k < p.constants.size(); ++k) + { + for (const uint64_t other : p.constants[k].constants) + { + if (constant_at[k] > 0 && other < constant_at.size() && (constant_at[other] == 0 || constant_at[k] < constant_at[other])) + { + constant_at[other] = constant_at[k]; + } + } + } + for (size_t k = 0; k < p.constants.size(); ++k) + { + const S32 line = constant_at[k]; + if (line <= 0) + { + continue; + } + by_line[line - 1] += p.constants[k].bytes; + for (const uint64_t s : p.constants[k].strings) + { + if (s < string_at.size() && (string_at[s] == 0 || line < string_at[s])) + { + string_at[s] = line; + } + } + } + } if (!top && first > 0) { one.line = first - 1; @@ -539,6 +698,13 @@ namespace } weight.parts.push_back(std::move(one)); } + for (size_t s = 0; s < string_at.size(); ++s) + { + if (string_at[s] > 0 && s < read.stringBytes.size()) + { + by_line[string_at[s] - 1] += read.stringBytes[s]; + } + } for (const auto& [line, bytes] : by_line) { weight.lines.push_back({ line, bytes }); @@ -546,6 +712,184 @@ namespace } } +namespace +{ + // Every function of an SLua script, where it is, and what the script + // means it as where that is more than the compiler's name for it. + class SLuaFunctions final : public Luau::AstVisitor + { + public: + struct Meant + { + std::string name; + std::string within; + bool handler = false; + }; + std::vector all; + boost::unordered_flat_map meant; + + bool visit(Luau::AstExprFunction* node) override + { + all.push_back(node); + return true; + } + + // LLEvents:on("touch_start", function ...), LLEvents:once, and what + // LLTimers is given to call. + bool visit(Luau::AstExprCall* node) override + { + const auto* method = node->func->as(); + const auto* object = method ? method->expr->as() : nullptr; + if (!object || node->args.size == 0) + { + return true; + } + const std::string_view on = object->name.value; + const std::string_view what = method->index.value; + const auto* callback = node->args.data[node->args.size - 1]->as(); + if (!callback) + { + return true; + } + if (on == "LLEvents" && (what == "on" || what == "once")) + { + if (const auto* event = node->args.data[0]->as()) + { + meant[callback] = Meant{ std::string(event->value.data, event->value.size), std::string(), true }; + } + } + else if (on == "LLTimers") + { + meant[callback] = Meant{ "LLTimers:" + std::string(what), std::string(), false }; + } + return true; + } + + // LLEvents.touch_start = function ..., and a state's handlers in a + // table: states.default = { touch_start = function ... }, or + // states["name"] = { ... }. + bool visit(Luau::AstStatAssign* node) override + { + for (size_t i = 0; i < node->vars.size && i < node->values.size; ++i) + { + Luau::AstExpr* var = node->vars.data[i]; + Luau::AstExpr* value = node->values.data[i]; + if (const auto* field = var->as()) + { + const auto* object = field->expr->as(); + if (const auto* handler = value->as(); handler && object && std::string_view(object->name.value) == "LLEvents") + { + meant[handler] = Meant{ field->index.value, std::string(), true }; + } + if (named(field->expr, "states")) + { + stateTable(field->index.value, value); + } + } + else if (const auto* index = var->as(); index && named(index->expr, "states")) + { + if (const auto* key = index->index->as()) + { + stateTable(std::string(key->value.data, key->value.size), value); + } + } + } + return true; + } + + private: + static bool named(Luau::AstExpr* expr, std::string_view name) + { + if (const auto* local = expr->as()) + { + return std::string_view(local->local->name.value) == name; + } + const auto* global = expr->as(); + return global && std::string_view(global->name.value) == name; + } + + void stateTable(const std::string& state, Luau::AstExpr* value) + { + const auto* table = value->as(); + if (!table) + { + return; + } + for (const Luau::AstExprTable::Item& item : table->items) + { + const auto* key = item.key ? item.key->as() : nullptr; + const auto* handler = item.value ? item.value->as() : nullptr; + if (key && handler) + { + meant[handler] = Meant{ std::string(key->value.data, key->value.size), state, true }; + } + } + } + }; + + void nameSLuaParts(ALScriptWeight& weight, std::string_view source) + { + Luau::Allocator allocator; + Luau::AstNameTable names(allocator); + Luau::ParseOptions options; + Luau::ParseResult parsed = Luau::Parser::parse(source.data(), source.size(), names, allocator, options); + if (!parsed.root) + { + return; + } + SLuaFunctions found; + parsed.root->visit(&found); + // Each line's functions in the order the compiler finishes them -- + // one inside another first -- which is the order they end; and each + // line's prototypes in the order the bytecode has them. + std::map> on_line; + for (const Luau::AstExprFunction* function : found.all) + { + on_line[static_cast(function->location.begin.line)].push_back(function); + } + for (auto& [line, functions] : on_line) + { + std::stable_sort(functions.begin(), functions.end(), + [](const Luau::AstExprFunction* a, const Luau::AstExprFunction* b) { return a->location.end < b->location.end; }); + } + std::map> parts_on; + for (ALScriptWeight::Part& part : weight.parts) + { + if (part.kind == ALScriptWeight::Part::Kind::Function && part.line >= 0) + { + parts_on[part.line].push_back(&part); + } + } + for (auto& [line, parts] : parts_on) + { + const auto functions = on_line.find(line); + // Only where the two agree on how many there are. + if (functions == on_line.end() || functions->second.size() != parts.size()) + { + continue; + } + for (size_t i = 0; i < parts.size(); ++i) + { + ALScriptWeight::Part& part = *parts[i]; + const Luau::AstExprFunction* function = functions->second[i]; + part.line = static_cast(function->location.begin.line); + part.column = static_cast(function->location.begin.column); + part.endLine = static_cast(function->location.end.line); + part.endColumn = static_cast(function->location.end.column); + if (const auto meant = found.meant.find(function); meant != found.meant.end()) + { + part.name = meant->second.name; + part.within = meant->second.within; + if (meant->second.handler) + { + part.kind = ALScriptWeight::Part::Kind::Handler; + } + } + } + } + } +} + // --- LSO: Tailslide's image ------------------------------------------------------------------ namespace diff --git a/indra/alscript/tests/alscriptweight_test.cpp b/indra/alscript/tests/alscriptweight_test.cpp index 783b372822..92093e2c06 100644 --- a/indra/alscript/tests/alscriptweight_test.cpp +++ b/indra/alscript/tests/alscriptweight_test.cpp @@ -493,4 +493,60 @@ namespace tut ensure("the second less than the string" + lined(two), two.total - one.total < 20); ensure("the string is the first line's" + lined(two), at(two, 4) > at(two, 5) + 80); } + + // SLua's handlers and callbacks are named as the script means them, a + // state's with its state, each part from its function to its end; and + // a line carries the strings and constants it is first to name. + template<> template<> + void alscriptweight_object::test<12>() + { + const std::string script = "local greeting = \"a sentence of some length, said to the owner as the script starts up\"\n" + "local function twice(n: number): number\n" + " return n * 2\n" + "end\n" + "local states = {}\n" + "states.default = {\n" + " state_entry = function()\n" + " ll.OwnerSay(greeting)\n" + " end,\n" + "}\n" + "states[\"other place\"] = { touch_start = function(events) print(twice(1)) end }\n" + "LLEvents:on(\"touch_start\", function(events)\n" + " print(\"touched\")\n" + "end)\n" + "LLTimers:every(1, function()\n" + " print(\"tick\")\n" + "end)\n"; + const ALScriptWeight weight = ALScriptWeigh::slua(script); + ensure("compiled: " + weight.error, weight.compiled); + const auto find = [&](const std::string& name, const std::string& within) -> const ALScriptWeight::Part* { + for (const ALScriptWeight::Part& part : weight.parts) + { + if (part.name == name && part.within == within) + { + return ∂ + } + } + return nullptr; + }; + const ALScriptWeight::Part* twice = find("twice", ""); + ensure("a local function, from its function to its end:" + listed(weight), twice && twice->line == 1 && twice->endLine == 3 && twice->endColumn == 3); + ensure("not a handler", twice->kind == ALScriptWeight::Part::Kind::Function); + const ALScriptWeight::Part* entry = find("state_entry", "default"); + ensure("a state's handler, with its state:" + listed(weight), entry && entry->kind == ALScriptWeight::Part::Kind::Handler); + ensure("from its function to its end", entry->line == 6 && entry->column == 18 && entry->endLine == 8); + ensure("a state named by a string:" + listed(weight), find("touch_start", "other place") != nullptr); + const ALScriptWeight::Part* touched = find("touch_start", ""); + ensure("LLEvents' handler by its event:" + listed(weight), touched && touched->kind == ALScriptWeight::Part::Kind::Handler && touched->line == 11); + const ALScriptWeight::Part* tick = find("LLTimers:every", ""); + ensure("what LLTimers calls, by how it is set going:" + listed(weight), tick && tick->kind == ALScriptWeight::Part::Kind::Function); + + // The greeting, a constant the compiler puts where it is used, is + // that line's; the strings every line names are counted on some + // line, so that the lines come to most of the whole. + ensure("the long string on the line that says it:" + lined(weight), at(weight, 7) > 80); + ensure("a short one on its own line:" + lined(weight), at(weight, 12) > 4 + std::string("touched").size()); + const size_t lines = within(weight, 0, 100); + ensure("most of the whole on some line: " + std::to_string(lines) + " of " + std::to_string(weight.total), lines * 3 > weight.total * 2); + } } diff --git a/indra/newview/alscriptweightspane.cpp b/indra/newview/alscriptweightspane.cpp index ed3fec8637..689a911b92 100644 --- a/indra/newview/alscriptweightspane.cpp +++ b/indra/newview/alscriptweightspane.cpp @@ -294,6 +294,13 @@ void ALScriptWeightsPane::fillParts() { head += " " + mStrings->getString("WeightsHeadError", args); } + // A compiler that records no lines -- LSL's for Luau -- leaves the + // heat beside the text with nothing to show: said, rather than left to + // look like a script whose lines weigh nothing. + if (weight->target == ALScriptWeight::Target::LSLLuau && weight->compiled && weight->total > 0 && weight->lines.empty()) + { + head += " " + mStrings->getString("WeightsHeadNoLines", args); + } if (mShown.beforeOptimizer) { const bool own = !mShown.weights.empty() && weight->target == mShown.weights.front().target; diff --git a/indra/newview/skins/default/xui/en/floater_script_studio.xml b/indra/newview/skins/default/xui/en/floater_script_studio.xml index ebe04e13d5..03df89f294 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio.xml @@ -671,6 +671,7 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is [NAME] on [TARGET]: about [SIZE,number,1] of [LIMIT] KB used, [SHARE]. [NAME] does not compile for [TARGET]: [ERROR] Currently, [ERROR]. + Its lines are not weighed one by one: the compiler for [TARGET] records no lines. Measured before the preprocessor's optimizer, which runs on save. Measured before the preprocessor's optimizer. As saved, it is [SENT,number,1] KB. Code size only, not counting the lists and strings the script builds while it runs. diff --git a/indra/newview/tests/alscriptweightspane_test.cpp b/indra/newview/tests/alscriptweightspane_test.cpp index 91c1742593..666ffd19f8 100644 --- a/indra/newview/tests/alscriptweightspane_test.cpp +++ b/indra/newview/tests/alscriptweightspane_test.cpp @@ -364,4 +364,29 @@ namespace tut pane.show(shown); ensure("and URLs where there are" + head->getText(), head->getText().find("128 KB and 3 URLs") != std::string::npos); } + + // LSL on Luau's compiler records no lines, so its lines are not + // weighed one by one: said, where its heat would show nothing; not of + // a target whose lines merely weigh nothing. + template<> template<> + void alscriptweightspane_object::test<8>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ensure("the studio's window builds with its Weights tab", tab() != nullptr); + ALScriptWeightsPane& pane = weights(); + ALScriptWeightsPane::Shown shown = lsl(); + shown.weights[2].compiled = true; + shown.weights[2].error.clear(); + shown.weights[2].total = 2048; + std::swap(shown.weights[0], shown.weights[2]); + pane.show(shown); + const std::string luau = floater->findChild("weights_head", true)->getText(); + ensure("said for LSL on Luau: " + luau, luau.find("records no lines") != std::string::npos); + pane.show(lsl()); + const std::string lso = floater->findChild("weights_head", true)->getText(); + ensure("not for LSO: " + lso, lso.find("records no lines") == std::string::npos); + } } From 151f6bd9ab839db71563252a0d16fb8f99f0222c Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 15:13:26 -0400 Subject: [PATCH 09/41] vcpkg installs again when the overlay ports asked for change The install stamp left VCPKG_OVERLAY_PORTS out of what it keys on, so a configure that set an overlay -- a port being tried before its registry has it -- said nothing had changed and installed nothing. An overlay port's own files changing is still not seen; deleting the stamp is. Co-Authored-By: Claude Opus 5.5 --- indra/cmake/BootstrapVcpkg.cmake | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/indra/cmake/BootstrapVcpkg.cmake b/indra/cmake/BootstrapVcpkg.cmake index 3076020a21..f1c37bb73c 100644 --- a/indra/cmake/BootstrapVcpkg.cmake +++ b/indra/cmake/BootstrapVcpkg.cmake @@ -198,7 +198,8 @@ endfunction() # and a run that installs nothing still costs seconds. It runs only when # something it reads has changed since the install that last ran: the # manifest, the registry configuration, the triplets and what they include, -# the feature list, the triplet, and the vcpkg tool. The root writes the +# the feature list, the triplet, the overlay ports asked for, and the vcpkg +# tool. An overlay port's own files changing is not seen: delete the stamp. The root writes the # stamp once project() has returned, which is after the install succeeded. # AL_VCPKG_INSTALL off leaves the ports to you; deleting the stamp file # forces one install. @@ -235,6 +236,7 @@ if(AL_VCPKG_INSTALL) string(APPEND al_vcpkg_install_key "triplet=${VCPKG_TARGET_TRIPLET};") string(APPEND al_vcpkg_install_key "features=${VCPKG_MANIFEST_FEATURES};") string(APPEND al_vcpkg_install_key "toolchain=${CMAKE_TOOLCHAIN_FILE};") + string(APPEND al_vcpkg_install_key "overlays=${VCPKG_OVERLAY_PORTS};") if(DEFINED VCPKG_INSTALLED_DIR) set(al_vcpkg_installed_dir "${VCPKG_INSTALLED_DIR}") From e1cd65914c87c921d625a46973661870ffeee59c Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 15:24:14 -0400 Subject: [PATCH 10/41] LSL on Luau's weights come to each line The registry's slua (2026-09-17#2) lets the fork's LSL compiler record the line of each instruction, as SLua's compiler does, which it never did. The weigher compiles twice: as the server does, for the total and the parts, and with lines, for what each line comes to. The lines are not in the server's asset, so the total stays what the server charges. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/alscriptweight.cpp | 11 +++++++++++ indra/alscript/tests/alscriptweight_test.cpp | 8 +++++++- indra/vcpkg-configuration.json | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/indra/alscript/alscriptweight.cpp b/indra/alscript/alscriptweight.cpp index 41088db198..a2ba1903d0 100644 --- a/indra/alscript/alscriptweight.cpp +++ b/indra/alscript/alscriptweight.cpp @@ -573,9 +573,14 @@ namespace ALScriptWeigh } AL_SCRIPT_ENGINE_HELD; std::string asset; + std::string lined; try { asset = compileLSLAssetOrThrow(std::string(source), 0); + // Again with each instruction's line, which the server's asset + // has not: what each line comes to is read off this one, the + // rest off the server's. + lined = compileLSLAssetOrThrow(std::string(source), 0, true); } catch (const Luau::ParseErrors& e) { @@ -588,6 +593,12 @@ namespace ALScriptWeigh return weight; } weighAsset(weight, asset); + if (weight.compiled && !lined.empty()) + { + ALScriptWeight by_line; + weighAsset(by_line, lined); + weight.lines = std::move(by_line.lines); + } nameLSLParts(weight, source); return weight; } diff --git a/indra/alscript/tests/alscriptweight_test.cpp b/indra/alscript/tests/alscriptweight_test.cpp index 92093e2c06..9f8653edcd 100644 --- a/indra/alscript/tests/alscriptweight_test.cpp +++ b/indra/alscript/tests/alscriptweight_test.cpp @@ -303,7 +303,8 @@ namespace tut } // LSL on Luau: the fork's own compiler, the asset as the server makes - // it, the bytecode read back as SLua's is. + // it, the bytecode read back as SLua's is; and again with its lines, + // for what each comes to. template<> template<> void alscriptweight_object::test<6>() { @@ -335,6 +336,11 @@ namespace tut const ALScriptWeight::Part* touch = named(weight, "touch_start"); ensure("a handler in its state:" + listed(weight), touch && touch->within == "default" && touch->kind == ALScriptWeight::Part::Kind::Handler); ensure_equals("where it is", touch->line, 11); + // Its lines, read off the compiler's lined bytecode, which the + // server's is not: the total stays what the server charges. + ensure("each line weighed:" + lined(weight), at(weight, 9) > 0 && at(weight, 13) > 0 && at(weight, 3) > 0); + ensure("the handler's long line its own:" + lined(weight), at(weight, 13) > at(weight, 9)); + ensure("nothing past the script's lines:" + lined(weight), within(weight, 16, 1000) == 0); const ALScriptWeight broken = ALScriptWeigh::lslLuau("default { state_entry() { integer x = ; } }\n"); ensure("a script that does not compile", !broken.compiled && !broken.error.empty()); } diff --git a/indra/vcpkg-configuration.json b/indra/vcpkg-configuration.json index 8f9a230320..908dd5a65e 100644 --- a/indra/vcpkg-configuration.json +++ b/indra/vcpkg-configuration.json @@ -2,7 +2,7 @@ "registries": [ { "kind": "git", - "baseline": "89a3892bd4404e50c7e4a1bdc2f9d810e4cdec32", + "baseline": "a82d34f8502b314d7a5b2ff961527fb8877ac5b7", "reference": "main", "repository": "https://github.com/AlchemyViewer/alchemy-registry", "packages": [ From 8516b8bef31560d9b8a676aab5e235009363f239 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 15:32:20 -0400 Subject: [PATCH 11/41] On Linux what is copied is the viewer's to keep, not SDL's SDL frees the text it is given to copy when it decides another client has taken the clipboard, while the Wayland source or X11 owner it gave that text to can go on serving it; 3.4.18 decides so on Wayland each time keyboard focus leaves with an offer held. Another application then pasted a few bytes of whatever had since been allocated there, or nothing, from chat, notecards, line editors and the studio alike. The viewer now holds the text and gives SDL a callback onto it with a cleanup that does nothing, under the names the X11 and Wayland backends use for text. Other platforms keep SDL_SetClipboardText. Co-Authored-By: Claude Opus 5.5 --- indra/llwindow/llwindowsdl.cpp | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/indra/llwindow/llwindowsdl.cpp b/indra/llwindow/llwindowsdl.cpp index 281e142b17..d0dd3f934c 100644 --- a/indra/llwindow/llwindowsdl.cpp +++ b/indra/llwindow/llwindowsdl.cpp @@ -1361,9 +1361,46 @@ bool LLWindowSDL::pasteTextFromClipboard(std::string &dst) return false; } +#if LL_LINUX +namespace +{ + // What was last copied, held here rather than by SDL. SDL frees the text + // it was given when it decides another client has the clipboard, while + // the Wayland source or X11 owner it handed that text to can go on + // serving it (SDL issues 16037 and 16245; 3.4.18 decides so on Wayland + // each time keyboard focus leaves with an offer held), and another + // application pasted whatever had since been allocated there. SDL's + // cleanup does nothing, so whatever still serves the text reads this. + std::string sClipboardText; + + // Every name the X11 and Wayland backends give text by: theirs when + // SDL_SetClipboardText chooses, so that SDL_GetClipboardText finds them. + const char* const CLIPBOARD_TEXT_TYPES[] = { + "text/plain;charset=utf-8", "UTF8_STRING", "text/plain", "TEXT", "STRING" + }; + + const void* SDLCALL clipboardText(void*, const char*, size_t* size) + { + *size = sClipboardText.size(); + return sClipboardText.data(); + } + + void SDLCALL keepClipboardText(void*) {} +} +#endif + bool LLWindowSDL::copyTextToClipboard(const std::string& text) { +#if LL_LINUX + if (text.empty()) + { + return SDL_ClearClipboardData(); + } + sClipboardText = text; + return SDL_SetClipboardData(clipboardText, keepClipboardText, nullptr, CLIPBOARD_TEXT_TYPES, std::size(CLIPBOARD_TEXT_TYPES)); +#else return SDL_SetClipboardText(text.c_str()); +#endif } bool LLWindowSDL::isPrimaryTextAvailable() From 5edd96f0c1b53247c006e1102b5f768faec4243b Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 16:23:21 -0400 Subject: [PATCH 12/41] SDL keeps the clipboard the viewer set as its own The registry's sdl3 (3.4.18#1) stops SDL deciding another client has the clipboard when it has not -- on Wayland when keyboard focus leaves or a clipboard manager serves a copy of the viewer's text, on X11 when a stale TARGETS reply arrives -- so that the viewer's own copy pastes back into it. The viewer still holds what it copied, for any such decision the patch does not cover. Co-Authored-By: Claude Opus 5.5 --- indra/llwindow/llwindowsdl.cpp | 7 ++++--- indra/vcpkg-configuration.json | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/indra/llwindow/llwindowsdl.cpp b/indra/llwindow/llwindowsdl.cpp index d0dd3f934c..4076b97e5c 100644 --- a/indra/llwindow/llwindowsdl.cpp +++ b/indra/llwindow/llwindowsdl.cpp @@ -1367,9 +1367,10 @@ namespace // What was last copied, held here rather than by SDL. SDL frees the text // it was given when it decides another client has the clipboard, while // the Wayland source or X11 owner it handed that text to can go on - // serving it (SDL issues 16037 and 16245; 3.4.18 decides so on Wayland - // each time keyboard focus leaves with an offer held), and another - // application pasted whatever had since been allocated there. SDL's + // serving it (SDL issues 16037 and 16245), and another application + // pasted whatever had since been allocated there. The registry's sdl3 + // patches the wrong decisions known on 3.4.18 + // (clipboard-keep-own-selection.patch); this stands for any left. SDL's // cleanup does nothing, so whatever still serves the text reads this. std::string sClipboardText; diff --git a/indra/vcpkg-configuration.json b/indra/vcpkg-configuration.json index 908dd5a65e..284bbb79a7 100644 --- a/indra/vcpkg-configuration.json +++ b/indra/vcpkg-configuration.json @@ -2,7 +2,7 @@ "registries": [ { "kind": "git", - "baseline": "a82d34f8502b314d7a5b2ff961527fb8877ac5b7", + "baseline": "697db3f88df7938a7b25ab27f9f03268f959aed6", "reference": "main", "repository": "https://github.com/AlchemyViewer/alchemy-registry", "packages": [ From 81542cc887bcb99b68a43820ceaf8c336dc8d90f Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 16:40:43 -0400 Subject: [PATCH 13/41] A column is selected by Shift-Alt-drag and Control-Shift-Alt with an arrow Shift-Alt-drag -- Shift-Option on a Mac -- selects a column from the anchor to the mouse, and Control-Shift-Alt with an arrow -- Command- Shift-Option -- grows one a column or a row at a time, or begins one from the selection there is. Each row between the corners gets a selection from one corner's x to the other's, so that a tab or a wide character counts as wide as it is drawn and a row too short gets a caret at its end; the row the corner moves on is the main one, and each caret keeps the column's x for the motions after. On Windows and Linux those keys had selected by a name's parts; the column takes them, as Visual Studio Code has it, and selecting by parts there is left for a key given it by hand. The Mac keeps both. Co-Authored-By: Claude Opus 5.5 --- indra/llui/alkeymap.cpp | 12 + indra/llui/alkeymap.h | 7 + indra/llui/altextview.cpp | 225 +++++++++++++++--- indra/llui/altextview.h | 39 +++ indra/llui/tests/alcodeeditor_test.cpp | 8 +- indra/llui/tests/altextview_test.cpp | 111 +++++++++ indra/newview/alfloaterscriptstudio.cpp | 6 + .../xui/en/panel_script_studio_keys.xml | 4 + 8 files changed, 372 insertions(+), 40 deletions(-) diff --git a/indra/llui/alkeymap.cpp b/indra/llui/alkeymap.cpp index e99ff8ce31..d3ede52aea 100644 --- a/indra/llui/alkeymap.cpp +++ b/indra/llui/alkeymap.cpp @@ -48,6 +48,7 @@ const char* alEditorCommandName(ALEditorCommand command) "go_to_bracket", "insert_line_below", "insert_line_above", "select_line", "move_subword_left", "move_subword_right", "select_subword_left", "select_subword_right", "expand_selection", "shrink_selection", "select_next_occurrence", "change_all_occurrences", "add_caret_above", "add_caret_below", + "column_select_left", "column_select_right", "column_select_up", "column_select_down", }; static_assert(sizeof(NAMES) / sizeof(NAMES[0]) == static_cast(ALEditorCommand::COUNT), "every command has a name"); const size_t index = static_cast(command); @@ -268,8 +269,13 @@ ALKeymap ALKeymap::standard() #endif map.bind(KEY_LEFT, part, C::MoveSubwordLeft); map.bind(KEY_RIGHT, part, C::MoveSubwordRight); +#if LL_DARWIN map.bind(KEY_LEFT, part | MASK_SHIFT, C::SelectSubwordLeft); map.bind(KEY_RIGHT, part | MASK_SHIFT, C::SelectSubwordRight); +#endif + // Elsewhere Control-Shift-Alt with an arrow grows a column (below), as + // Visual Studio Code has it; selecting by parts there takes a key + // given it by hand. #if LL_DARWIN // Control-Shift-Command with an arrow there, Shift-Option being words. const MASK grow = MASK_MAC_CONTROL | MASK_SHIFT | MASK_CONTROL; @@ -285,6 +291,12 @@ ALKeymap ALKeymap::standard() // As Visual Studio Code has them: Command and Option on a Mac. map.bind(KEY_UP, MASK_CONTROL | MASK_ALT, C::AddCaretAbove); map.bind(KEY_DOWN, MASK_CONTROL | MASK_ALT, C::AddCaretBelow); + // As Visual Studio Code has them: Command-Shift-Option on a Mac. + const MASK column = MASK_CONTROL | MASK_SHIFT | MASK_ALT; + map.bind(KEY_LEFT, column, C::ColumnSelectLeft); + map.bind(KEY_RIGHT, column, C::ColumnSelectRight); + map.bind(KEY_UP, column, C::ColumnSelectUp); + map.bind(KEY_DOWN, column, C::ColumnSelectDown); #if LL_DARWIN // The Mac's own, where MASK_CONTROL is Command: Command with an arrow // goes to the ends of the line and of the text, Option with one by diff --git a/indra/llui/alkeymap.h b/indra/llui/alkeymap.h index 2e9459ba5f..8373d1f683 100644 --- a/indra/llui/alkeymap.h +++ b/indra/llui/alkeymap.h @@ -131,6 +131,13 @@ enum class ALEditorCommand : U8 // them grown by a row. AddCaretAbove, AddCaretBelow, + // A column selection grown at its moving corner by a column either + // way or a row up or down: begun from the selection there is where + // there is no column. + ColumnSelectLeft, + ColumnSelectRight, + ColumnSelectUp, + ColumnSelectDown, COUNT }; diff --git a/indra/llui/altextview.cpp b/indra/llui/altextview.cpp index 6e3ac5c17f..b57dd39cfe 100644 --- a/indra/llui/altextview.cpp +++ b/indra/llui/altextview.cpp @@ -177,6 +177,10 @@ namespace case ALEditorCommand::ChangeAllOccurrences: case ALEditorCommand::AddCaretAbove: case ALEditorCommand::AddCaretBelow: + case ALEditorCommand::ColumnSelectLeft: + case ALEditorCommand::ColumnSelectRight: + case ALEditorCommand::ColumnSelectUp: + case ALEditorCommand::ColumnSelectDown: case ALEditorCommand::FindNext: case ALEditorCommand::FindPrevious: case ALEditorCommand::COUNT: @@ -1086,6 +1090,123 @@ bool ALTextView::addCarets(S32 direction) return true; } +ALTextView::ColumnCorner ALTextView::cornerAt(const ALTextPos& pos) +{ + ColumnCorner corner; + corner.line = pos.line; + corner.x = mLayout.xOf(pos.line, pos.column, &corner.row); + return corner; +} + +ALTextView::ColumnCorner ALTextView::cornerAtLocal(S32 x, S32 y) +{ + const LLRect text = textRect(); + ColumnCorner corner; + if (mLayout.rowHeight() <= 0 || mDocument.lineCount() == 0) + { + return corner; + } + const S32 doc_y = (text.mTop - y) + mScrollY; + corner.line = mLayout.lineAtY(llmax(0, doc_y)); + corner.row = mLayout.rowAtY(corner.line, doc_y - mLayout.lineTop(corner.line)); + corner.x = llmax(0.f, static_cast(x - text.mLeft) + mScrollX); + return corner; +} + +bool ALTextView::stepRow(S32& line, S32& row, S32 direction) +{ + if (direction < 0) + { + if (row > 0) + { + --row; + return true; + } + const S32 above = mLayout.visibleFrom(line - 1, -1); + if (above < 0) + { + return false; + } + line = above; + row = mLayout.rowCount(line) - 1; + return true; + } + if (row + 1 < mLayout.rowCount(line)) + { + ++row; + return true; + } + const S32 below = mLayout.visibleFrom(line + 1, 1); + if (below < 0) + { + return false; + } + line = below; + row = 0; + return true; +} + +std::pair ALTextView::columnCorners() +{ + if (!mColumn.selections.empty() && selectionsInOrder() == mColumn.selections) + { + return { mColumn.from, mColumn.to }; + } + return { cornerAt(mAnchor), cornerAt(mCaret) }; +} + +void ALTextView::selectColumn(const ColumnCorner& from, const ColumnCorner& to) +{ + const bool down = from.line < to.line || (from.line == to.line && from.row <= to.row); + // From the higher corner's row down to the lower's, each row's columns + // at the two x's, nearest as drawn: past a row's end, its end. + S32 line = down ? from.line : to.line; + S32 row = down ? from.row : to.row; + const ColumnCorner& last = down ? to : from; + std::vector rows; + while (true) + { + row = llclamp(row, 0, llmax(0, mLayout.rowCount(line) - 1)); + rows.emplace_back(ALTextPos(line, mLayout.columnAt(line, row, from.x, true)), ALTextPos(line, mLayout.columnAt(line, row, to.x, true))); + if (line > last.line || (line == last.line && row >= last.row) || !stepRow(line, row, 1)) + { + break; + } + } + placeSelections(rows, down ? rows.size() - 1 : 0); + mColumn.from = from; + mColumn.to = to; + mColumn.selections = selectionsInOrder(); + // Each caret keeps the column's x between rows, as carets added above + // and below do, so that moving them keeps the column past short rows. + mEachDesired.selections = mColumn.selections; + mEachDesired.xs.assign(mColumn.selections.size(), to.x); + mDesiredX = to.x; + scrollToCaret(); +} + +bool ALTextView::growColumn(S32 columns, S32 rows) +{ + if (mLayout.rowHeight() <= 0 || mDocument.lineCount() == 0) + { + return false; + } + auto [from, to] = columnCorners(); + if (rows != 0 && !stepRow(to.line, to.row, rows)) + { + return false; + } + if (columns < 0 && to.x <= 0.f && rows == 0) + { + return false; + } + // A column is a space's advance: a tab or a wide character is crossed + // in as many steps as it is drawn wide, its edge the nearest. + to.x = llmax(0.f, to.x + static_cast(columns) * mLayout.columnWidth()); + selectColumn(from, to); + return true; +} + S32 ALTextView::selectAllMatches() { settleFind(); @@ -1987,38 +2108,12 @@ ALTextPos ALTextView::rowsFrom(const ALTextPos& from, S32 rows, F32& desired_x) desired_x = x; } S32 line = from.line; - for (; rows < 0; ++rows) - { - if (row > 0) - { - --row; - } - else if (const S32 above = mLayout.visibleFrom(line - 1, -1); above >= 0) - { - line = above; - row = mLayout.rowCount(line) - 1; - } - else - { - desired_x = -1.f; - return mDocument.start(); - } - } - for (; rows > 0; --rows) + for (; rows != 0; rows += rows < 0 ? 1 : -1) { - if (row + 1 < mLayout.rowCount(line)) - { - ++row; - } - else if (const S32 below = mLayout.visibleFrom(line + 1, 1); below >= 0) - { - line = below; - row = 0; - } - else + if (!stepRow(line, row, rows)) { desired_x = -1.f; - return mDocument.end(); + return rows < 0 ? mDocument.start() : mDocument.end(); } } return ALTextPos(line, mLayout.columnAt(line, row, desired_x, true)); @@ -2843,14 +2938,18 @@ std::optional ALTextView::performAtEach(ALEditorCommand command) scrollToCaret(); return true; } - // Done as they always are: those that add carets, the clipboard's, - // the comment's and completion know of the others; select all, undo - // and redo put them as they put the main one; folding and the find - // bar shown leave them be. + // Done as they always are: those that add carets or grow a column, + // the clipboard's, the comment's and completion know of the others; + // select all, undo and redo put them as they put the main one; + // folding and the find bar shown leave them be. case C::None: case C::COUNT: case C::AddCaretAbove: case C::AddCaretBelow: + case C::ColumnSelectLeft: + case C::ColumnSelectRight: + case C::ColumnSelectUp: + case C::ColumnSelectDown: case C::SelectNextOccurrence: case C::ChangeAllOccurrences: case C::Complete: @@ -3268,6 +3367,14 @@ bool ALTextView::perform(ALEditorCommand command) case C::AddCaretAbove: case C::AddCaretBelow: return !mReadOnly && addCarets(command == C::AddCaretAbove ? -1 : 1); + case C::ColumnSelectLeft: + return !mReadOnly && growColumn(-1, 0); + case C::ColumnSelectRight: + return !mReadOnly && growColumn(1, 0); + case C::ColumnSelectUp: + return !mReadOnly && growColumn(0, -1); + case C::ColumnSelectDown: + return !mReadOnly && growColumn(0, 1); case C::Find: showFind(false); return true; @@ -3327,7 +3434,11 @@ bool ALTextView::canPerform(ALEditorCommand command) const return mFeatures && mFeatures->canPerformFeature(command); case C::AddCaretAbove: case C::AddCaretBelow: - // A read-only text shows no caret to add to. + case C::ColumnSelectLeft: + case C::ColumnSelectRight: + case C::ColumnSelectUp: + case C::ColumnSelectDown: + // A read-only text shows no caret to add to or grow from. return !mReadOnly; case C::PreviousChange: return mChangeAt > 0; @@ -4923,6 +5034,20 @@ void ALTextView::dragSelectTo(S32 x, S32 y) const S32 rows = 1 + past / row_h; setScrollY(mScrollY + (y > text.mTop ? -rows : rows) * row_h); } + if (mColumnDragging) + { + const ColumnCorner corner = cornerAtLocal(x, y); + if (corner == mColumn.to) + { + return; + } + selectColumn(mColumn.from, corner); + if (mModal) + { + mModal->mouseChanged(*this); + } + return; + } const ALTextPos at = posAtLocal(x, y, true); const ALTextPos character = posAtLocal(x, y, false); if (at == mDragAt && character == mDragToChar) @@ -5347,6 +5472,25 @@ bool ALTextView::handleMouseDown(S32 x, S32 y, MASK mask) } return true; } + // Shift-Alt -- Shift-Option on a Mac -- a column from the anchor to the + // press, as Shift-click takes a selection from it, grown as the mouse + // is dragged. + if (mask == (MASK_SHIFT | MASK_ALT) && !mReadOnly) + { + mPressedLink = -1; + mPressedAtom = -1; + selectColumn(columnCorners().first, cornerAtLocal(x, y)); + mColumnDragging = true; + mSelecting = true; + mDragX = x; + mDragY = y; + gFocusMgr.setMouseCapture(this); + if (mModal) + { + mModal->mouseChanged(*this); + } + return true; + } singleSelection(); // A link or an atom under the press is followed on the release, if // the release is on it too. @@ -5526,7 +5670,8 @@ bool ALTextView::handleMouseUp(S32 x, S32 y, MASK mask) } if (mSelecting) { - mSelecting = false; + mSelecting = false; + mColumnDragging = false; gFocusMgr.setMouseCapture(nullptr); if (mPrimaryStale) { @@ -5580,8 +5725,9 @@ bool ALTextView::handleDoubleClick(S32 x, S32 y, MASK mask) setFocus(true); const ALTextRange word = mDocument.wordAt(posAtLocal(x, y, false)); placeSelection(word.begin, word.end); - mDesiredX = -1.f; - mSelecting = false; + mDesiredX = -1.f; + mSelecting = false; + mColumnDragging = false; armTripleClick(); if (mModal) { @@ -5642,8 +5788,9 @@ bool ALTextView::handleScrollHWheel(S32 x, S32 y, LLScrollDelta delta) void ALTextView::onMouseCaptureLost() { - mSelecting = false; - mDraggingBar = false; + mSelecting = false; + mColumnDragging = false; + mDraggingBar = false; } bool ALTextView::handleToolTip(S32 x, S32 y, MASK mask) diff --git a/indra/llui/altextview.h b/indra/llui/altextview.h index 310df6ed36..5a662cc82c 100644 --- a/indra/llui/altextview.h +++ b/indra/llui/altextview.h @@ -373,6 +373,15 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec // its column as a motion between rows does; the furthest the main // one. False where every caret is on the first or the last row. bool addCarets(S32 direction); + // A column selection grown at its moving corner by so many columns + // sideways and rows down, or up below zero; begun from the main + // selection's anchor and caret where the selections are not a column's. + // Each row between the corners has a selection from the x of the corner + // that stays to the x of the one that moves, so that a tab or a wide + // character counts as it is drawn and a row too short has a caret at + // its end; the one on the moving corner's row is the main one. False + // where it cannot go further that way. + bool growColumn(S32 columns, S32 rows); // Every match the find bar has selected, the current one the main // one, and the keyboard to the text; how many. S32 selectAllMatches(); @@ -1003,6 +1012,36 @@ class ALTextView : public LLUICtrl, public LLEditMenuHandler, public LLSpellChec std::vector xs; }; EachDesired mEachDesired; + // One row on from a row of a line, down or up below zero, past the + // lines folded away; false at the first or the last. + bool stepRow(S32& line, S32& row, S32 direction); + // A corner of a column selection: a row of a line, and an x from the + // row's start that may lie past its end. + struct ColumnCorner + { + S32 line = 0; + S32 row = 0; + F32 x = 0.f; + bool operator==(const ColumnCorner&) const = default; + }; + ColumnCorner cornerAt(const ALTextPos& pos); + ColumnCorner cornerAtLocal(S32 x, S32 y); + // The selections from one corner to the other (growColumn), kept as + // the column for as long as they stand as they were put. + void selectColumn(const ColumnCorner& from, const ColumnCorner& to); + // The corners the column grows from now: its own while the selections + // are the ones it put, else the main selection's anchor and caret. + std::pair columnCorners(); + struct Column + { + ColumnCorner from; + ColumnCorner to; + std::vector selections; + }; + Column mColumn; + // A drag begun with Shift-Alt, which puts a column rather than a + // selection. + bool mColumnDragging = false; // Return: the line split with the new one indented as the grammar // says, and a closing word before the caret brought out first. void newLine(); diff --git a/indra/llui/tests/alcodeeditor_test.cpp b/indra/llui/tests/alcodeeditor_test.cpp index ed2620bedf..c56c976742 100644 --- a/indra/llui/tests/alcodeeditor_test.cpp +++ b/indra/llui/tests/alcodeeditor_test.cpp @@ -2230,8 +2230,14 @@ namespace tut e.setCaret(ALTextPos(0, 10)); key(KEY_RIGHT, part); ensure("a part", e.caret() == ALTextPos(0, 12)); +#if LL_DARWIN key(KEY_RIGHT, part | MASK_SHIFT); - ensure("and a part selected", e.selection().normalised() == ALTextRange(ALTextPos(0, 12), ALTextPos(0, 15))); +#else + // A key of its own only on the Mac: Control-Shift-Alt with an + // arrow grows a column elsewhere. + e.perform(ALEditorCommand::SelectSubwordRight); +#endif + ensure("and a part selected",e.selection().normalised() == ALTextRange(ALTextPos(0, 12), ALTextPos(0, 15))); e.setCaret(ALTextPos(0, 18)); key(KEY_BACKSPACE, word); ensure_equals("a word taken back is the name", e.text(), std::string("ll.Say(0, );")); diff --git a/indra/llui/tests/altextview_test.cpp b/indra/llui/tests/altextview_test.cpp index 622e7e580d..accc71df40 100644 --- a/indra/llui/tests/altextview_test.cpp +++ b/indra/llui/tests/altextview_test.cpp @@ -2244,4 +2244,115 @@ namespace tut type("cow"); ensure_equals("typed over each", v.text(), std::string("cow dog cow bird cow")); } + + static ALTextRange spanRange(S32 line, S32 anchor, S32 caret) + { + return ALTextRange(ALTextPos(line, anchor), ALTextPos(line, caret)); + } + + template<> template<> + void altextview_object::test<72>() + { + set_test_name("Shift-Alt-drag selects a column from the anchor's x to the mouse's on each row, a tab counted as drawn and a short row a caret at its end, the mouse's row the main one; typing goes in at each, as one step"); + ALTextView& v = make("abcdefgh\nab\n\tcdefgh\nabcdefgh"); + S32 x, y; + pointOf(0, 3, x, y); + v.handleMouseDown(x, y, MASK_NONE); + v.handleMouseUp(x, y, MASK_NONE); + // Six columns in, on the line whose tab is four wide: after "cd". + S32 x6, y6, x_line2, y_line2; + pointOf(0, 6, x6, y6); + pointOf(2, 0, x_line2, y_line2); + ensure("pressed", v.handleMouseDown(x6, y_line2, MASK_SHIFT | MASK_ALT)); + ensure_equals("from column three to six on each row", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 6), caretRange(1, 2), spanRange(2, 1, 3) })); + ensure("the mouse's row the main one", v.selection() == spanRange(2, 1, 3)); + // Dragged back over the short row, to column one. + S32 x1, y1, x_line1, y_line1; + pointOf(0, 1, x1, y1); + pointOf(1, 0, x_line1, y_line1); + ensure("dragged", v.handleHover(x1, y_line1, MASK_SHIFT | MASK_ALT)); + ensure_equals("each from three back to one", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 1), spanRange(1, 2, 1) })); + v.handleMouseUp(x1, y_line1, MASK_SHIFT | MASK_ALT); + ensure("capture released", !v.hasMouseCapture()); + type("X"); + ensure_equals("typed at each", v.text(), std::string("aXdefgh\naX\n\tcdefgh\nabcdefgh")); + v.undo(); + ensure_equals("one step", v.text(), std::string("abcdefgh\nab\n\tcdefgh\nabcdefgh")); + + pointOf(3, 2, x, y); + v.handleMouseDown(x, y, MASK_NONE); + v.handleMouseUp(x, y, MASK_NONE); + ensure("a plain click is one caret again", !v.hasOtherSelections()); + v.setReadOnly(true); + v.handleMouseDown(x6, y_line2, MASK_SHIFT | MASK_ALT); + v.handleMouseUp(x6, y_line2, MASK_SHIFT | MASK_ALT); + ensure("no column in a read-only text", !v.hasOtherSelections()); + } + + template<> template<> + void altextview_object::test<73>() + { + set_test_name("Control-Shift-Alt with an arrow grows a column a column or a row at a time from the selection there is, past a short row and over a tab and a wide character as drawn; a row further than the text is refused"); + ALTextView& v = make("abcdefgh\nab\n\tcdefgh\nabcdefgh"); + const MASK column = MASK_CONTROL | MASK_SHIFT | MASK_ALT; + v.setCaret(ALTextPos(0, 3)); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + ensure("three columns on its row", v.selection() == spanRange(0, 3, 6) && !v.hasOtherSelections()); + key(KEY_DOWN, column); + key(KEY_DOWN, column); + ensure_equals("and two rows down", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 6), caretRange(1, 2), spanRange(2, 1, 3) })); + type("X"); + ensure_equals("typed at each", v.text(), std::string("abcXgh\nabX\n\tXefgh\nabcdefgh")); + v.undo(); + ensure_equals("one step", v.text(), std::string("abcdefgh\nab\n\tcdefgh\nabcdefgh")); + + // One caret first: the undone column's selections are back, and a + // caret put among them would be taken into the one it touches. + v.singleSelection(); + v.setCaret(ALTextPos(0, 3)); + key(KEY_DOWN, column); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + key(KEY_RIGHT, column); + key(KEY_LEFT, column); + ensure_equals("a row, then two columns", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(0, 3, 5), caretRange(1, 2) })); + key(KEY_UP, column); + key(KEY_LEFT, column); + key(KEY_LEFT, column); + key(KEY_LEFT, column); + ensure("back to its row and past where it began", v.selection() == spanRange(0, 3, 2) && !v.hasOtherSelections()); + ensure("no row above the first", !v.perform(ALEditorCommand::ColumnSelectUp)); + + // From a selection that is not a column: its anchor and caret the + // corners. Column one is nearer the tab's start than its end. + v.setSelection(spanRange(3, 1, 4)); + key(KEY_UP, column); + ensure_equals("the tab, as drawn, above three columns", rangesSaid(ll_test::TextViewProbe::selections(v)), + rangesSaid({ spanRange(2, 0, 1), spanRange(3, 1, 4) })); + ensure("the moving corner's row the main one", v.selection() == spanRange(2, 0, 1)); + + // Wide characters count as wide as the font draws them: the two of + // them selected, the row below gets as many columns as they are + // drawn across, to the nearest -- not one a character, nor a byte. + v.setText("\xe4\xb8\xad\xe6\x96\x87xy\nabcdefgh"); + const F32 width = v.layout().columnWidth(); + const F32 columns = v.layout().xOf(0, 6) / width; + if (llabs(columns - std::floor(columns) - 0.5f) > 0.1f) + { + v.singleSelection(); + v.setSelection(spanRange(0, 0, 6)); + key(KEY_DOWN, column); + ensure_equals("as many columns as they are drawn across", rangesSaid({ v.selection() }), + rangesSaid({ spanRange(1, 0, llround(columns)) })); + } + + v.setReadOnly(true); + ensure("none in a read-only text", !v.canPerform(ALEditorCommand::ColumnSelectDown) && !v.perform(ALEditorCommand::ColumnSelectDown)); + } } diff --git a/indra/newview/alfloaterscriptstudio.cpp b/indra/newview/alfloaterscriptstudio.cpp index 24269afdb6..a5d422f9e3 100644 --- a/indra/newview/alfloaterscriptstudio.cpp +++ b/indra/newview/alfloaterscriptstudio.cpp @@ -8132,6 +8132,12 @@ void ALFloaterScriptStudio::addEditCommands() addEditorCommand("change_all_occurrences", ALEditorCommand::ChangeAllOccurrences, true); addEditorCommand("add_caret_above", ALEditorCommand::AddCaretAbove, false); addEditorCommand("add_caret_below", ALEditorCommand::AddCaretBelow, false); + // Keys a step at a time rather than menu items: Shift-Alt-drag puts a + // column with the mouse. + addEditorCommand("column_select_left", ALEditorCommand::ColumnSelectLeft, false); + addEditorCommand("column_select_right", ALEditorCommand::ColumnSelectRight, false); + addEditorCommand("column_select_up", ALEditorCommand::ColumnSelectUp, false); + addEditorCommand("column_select_down", ALEditorCommand::ColumnSelectDown, false); mCommands.add( "convert_slua", [this]() { diff --git a/indra/newview/skins/default/xui/en/panel_script_studio_keys.xml b/indra/newview/skins/default/xui/en/panel_script_studio_keys.xml index e2a2bb5da3..8dc1031a1a 100644 --- a/indra/newview/skins/default/xui/en/panel_script_studio_keys.xml +++ b/indra/newview/skins/default/xui/en/panel_script_studio_keys.xml @@ -107,6 +107,10 @@ Change all occurrences Add a caret above Add a caret below + Grow the column selection left + Grow the column selection right + Grow the column selection up + Grow the column selection down Previous misspelling Date: Sat, 3 Oct 2026 16:42:52 -0400 Subject: [PATCH 14/41] A #define goes on over a backslash before CRLF, and past blanks after it A backslash joined its line to the next only where a newline came right after it. Before CRLF -- from a disk include, an external editor, an inventory include -- or with a space or a tab between, the backslash stood as a stray token, the macro's body ended there, and the lines meant for it went on as code, in LSL's #define and SLua's --#define alike. The preprocessor now ends a line at CRLF and at a lone CR as at LF, as C's first phase does and as the studio's own text already did. On a directive's line, blanks between the backslash and the newline are let by, as GCC and Clang let them by, with a warning that says so; elsewhere they join nothing, so that a note ending in a backslash and a space does not take the line of code after it. The place a fix puts a line at the head of a script sees such a directive's lines as one. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/alpreprocessor.cpp | 98 +++++++++++++++++-- indra/alscript/alscriptfixes.cpp | 11 ++- indra/alscript/tests/alpreprocessor_test.cpp | 44 +++++++++ .../alscript/tests/alpreprocessorc11_test.cpp | 39 ++++++++ .../newview/skins/default/xui/en/strings.xml | 1 + 5 files changed, 182 insertions(+), 11 deletions(-) diff --git a/indra/alscript/alpreprocessor.cpp b/indra/alscript/alpreprocessor.cpp index 74646b7604..b81e349aad 100644 --- a/indra/alscript/alpreprocessor.cpp +++ b/indra/alscript/alpreprocessor.cpp @@ -287,12 +287,35 @@ namespace Tokens run(std::string_view text) { + // A line ends at CRLF and at a lone CR as at LF (5.1.1.2 phase + // 1), as the studio's own text does: lexed from a copy with them + // made LF, where text from a disk, an external editor or an + // inventory include has them -- else a backslash before CRLF + // joins nothing and its CR is a blank. + if (!mVerbatim && text.find('\r') != std::string_view::npos) + { + mUnixText.clear(); + mUnixText.reserve(text.size()); + for (size_t i = 0; i < text.size(); ++i) + { + if (text[i] != '\r') + { + mUnixText += text[i]; + } + else if (i + 1 >= text.size() || text[i + 1] != '\n') + { + mUnixText += '\n'; + } + } + text = mUnixText; + } mText = text; mPos = 0; mLine = 0; mColumn = 0; mJoined = 0; mOut.clear(); + mSpacedJoins.clear(); while (mPos < mText.size()) { if (mLua) @@ -341,20 +364,64 @@ namespace // A backslash and a newline join two lines; both vanish, and the // newline that ends the joined line stands for them all, so that - // the lines after keep their numbers. + // the lines after keep their numbers. On a directive's line, blanks + // between the two are let by, as GCC and Clang let them by, with a + // warning: they are not seen, and what a #define then left behind + // went on as code. Not elsewhere, where a note ending in a + // backslash and a space would take the line of code after it. bool continuation() { - if (!mVerbatim && at(mPos) == '\\' && at(mPos + 1) == '\n') + if (mVerbatim || at(mPos) != '\\') { - mPos += 2; - ++mLine; - mColumn = 0; - ++mJoined; - return true; + return false; } - return false; + size_t newline = mPos + 1; + while (isBlank(at(newline))) + { + ++newline; + } + if (at(newline) != '\n') + { + return false; + } + if (newline > mPos + 1) + { + if (!onDirectiveLine()) + { + return false; + } + mSpacedJoins.push_back({ mLine, mColumn }); + } + mPos = newline + 1; + ++mLine; + mColumn = 0; + ++mJoined; + return true; + } + + // Whether the line being lexed -- since the last newline not + // joined away -- begins, past blanks and comments, with what starts + // a directive. + bool onDirectiveLine() const + { + const Token* first = nullptr; + for (auto it = mOut.rbegin(); it != mOut.rend() && it->kind != Kind::Newline; ++it) + { + if (it->kind != Kind::Space && it->kind != Kind::Comment) + { + first = &*it; + } + } + return first && first->kind == Kind::Punct && first->text == hashOf(mLua); } + public: + // Where a backslash was joined to its newline over blanks, by its + // line and column, for a warning. + const std::vector>& spacedJoins() const { return mSpacedJoins; } + + private: + // Every join where a token goes on: C joins the lines before it // reads a token (5.1.1.2 phase 2), so one runs on over a join -- // a name, a number, a directive's name, a // comment. LSL's alone: @@ -818,6 +885,9 @@ namespace S32 mJoined = 0; Token mToken; Tokens mOut; + // The text with its line ends made LF, where it had others. + std::string mUnixText; + std::vector> mSpacedJoins; }; // The text of a run of tokens, as written. @@ -1266,7 +1336,17 @@ namespace else { f->index = mResult.map.addFile(name, path); - f->lexed = std::make_shared(Lexer(mOptions.lua, f->index).run(text)); + Lexer lexer(mOptions.lua, f->index); + f->lexed = std::make_shared(lexer.run(text)); + for (const auto& [line, column] : lexer.spacedJoins()) + { + Token at; + at.file = f->index; + at.line = line; + at.column = column; + at.text = "\\"; + problem(ALScriptProblem::Severity::Warning, "PreprocBackslashSpace", "backslash and newline separated by space", {}, at); + } if (!path.empty()) { mLexed[path] = Lexed{ f->index, f->lexed }; diff --git a/indra/alscript/alscriptfixes.cpp b/indra/alscript/alscriptfixes.cpp index dcb59c51ee..8dff1afd97 100644 --- a/indra/alscript/alscriptfixes.cpp +++ b/indra/alscript/alscriptfixes.cpp @@ -853,9 +853,16 @@ namespace } if (!lua && line.front() == '#') { - // With the lines a backslash carries it on to. + // With the lines a backslash carries it on to, blanks after + // it let by as the preprocessor lets them by on a directive's + // line. + const auto carried = [&lines](S32 at) { + const std::string_view text = lines.line(at); + const size_t last = text.find_last_not_of(" \t\r\f\v"); + return last != std::string_view::npos && text[last] == '\\'; + }; last_kind = i; - while (!lines.line(last_kind).empty() && lines.line(last_kind).back() == '\\' && last_kind + 1 < lines.count()) + while (carried(last_kind) && last_kind + 1 < lines.count()) { ++last_kind; } diff --git a/indra/alscript/tests/alpreprocessor_test.cpp b/indra/alscript/tests/alpreprocessor_test.cpp index b7cad252c2..ab086b1b08 100644 --- a/indra/alscript/tests/alpreprocessor_test.cpp +++ b/indra/alscript/tests/alpreprocessor_test.cpp @@ -1587,4 +1587,48 @@ namespace tut const std::vector tokens = ALPreprocessor::tokenize("--#define A 1\n#a\n", true); ensure("the mark, then the directive's words", tokens.size() > 2 && tokens[0].text == "--#" && tokens[1].text == "define"); } + + template<> template<> + void alpreprocessor_object::test<42>() + { + set_test_name("SLua: a --#define goes on over a backslash at its line's end, before LF, CRLF or a lone CR, and past blanks with a warning; an include's CRLF lines read as LF"); + const std::string lf = "--#define SAY(x) \\\n" + " ll.OwnerSay(x) \\\n" + " print(x)\n" + "SAY(\"hi\")\n"; + ALPreprocessor::Result r = ALPreprocessor::run(lf, options(true)); + ensure_equals("nothing wrong", messages(r), std::string()); + const std::string joined = r.text; + const size_t used = joined.find("ll.OwnerSay(\"hi\")"); + ensure("the body on the line that uses it: " + joined, + used != std::string::npos && joined.find("print(\"hi\")") > used && joined.find('\n', used) > joined.find("print(\"hi\")") && + joined.find('\\') == std::string::npos); + const auto ended = [](const std::string& text, const char* end) { + std::string out; + for (char c : text) + { + out += c == '\n' ? std::string(end) : std::string(1, c); + } + return out; + }; + r = ALPreprocessor::run(ended(lf, "\r\n"), options(true)); + ensure_equals("CRLF said nothing", messages(r), std::string()); + ensure_equals("CRLF", r.text, joined); + r = ALPreprocessor::run(ended(lf, "\r"), options(true)); + ensure_equals("a lone CR", r.text, joined); + + r = ALPreprocessor::run("--#define SAY(x) \\ \n" + " ll.OwnerSay(x) \\\t\n" + " print(x)\n" + "SAY(\"hi\")\n", + options(true)); + ensure_equals("blanks after the backslash", r.text, joined); + ensure_equals("said of each", messages(r), + std::string("W 0: backslash and newline separated by space\nW 1: backslash and newline separated by space\n")); + + add("say.luau", "--#define SAY(x) \\\r\n print(x)\r\n"); + r = ALPreprocessor::run("--#include \"say.luau\"\nSAY(1)\n", options(true)); + ensure_equals("an include with CRLF said nothing", messages(r), std::string()); + ensure("and its macro goes on over its lines: " + r.text, r.text.find("print(1)") != std::string::npos); + } } diff --git a/indra/alscript/tests/alpreprocessorc11_test.cpp b/indra/alscript/tests/alpreprocessorc11_test.cpp index a6d5493f78..5815a7e1ed 100644 --- a/indra/alscript/tests/alpreprocessorc11_test.cpp +++ b/indra/alscript/tests/alpreprocessorc11_test.cpp @@ -946,4 +946,43 @@ namespace tut ensure_equals("taken", flat(r.text), std::string("one two three four")); } } + + template<> template<> + void alpreprocessorc11_object::test<37>() + { + set_test_name("5.1.1.2: a line ends at CRLF and at a lone CR as at LF, so a backslash before either joins; on a directive's line blanks between the backslash and the newline are let by with a warning, elsewhere they join nothing"); + const auto ended = [](std::string text, const char* end) { + std::string out; + for (char c : text) + { + out += c == '\n' ? std::string(end) : std::string(1, c); + } + return out; + }; + const std::string lf = "#define SAY(x) \\\n" + " llOwnerSay(x); \\\n" + " llSay(0, x)\n" + "default { state_entry() { SAY(\"hi\"); } }\n"; + const std::string joined = pp(lf); + ensure("the macro's lines are its body: " + joined, joined.find("PROBLEMS") == std::string::npos && joined.find("llSay") != std::string::npos && + joined.find('\\') == std::string::npos && joined.find("default") == 0); + ensure_equals("CRLF", pp(ended(lf, "\r\n")), joined); + ensure_equals("a lone CR", pp(ended(lf, "\r")), joined); + ensure_equals("the lines after keep their numbers", pp(ended("#define A 1 \\\n + 2\nA __LINE__\n", "\r\n")), std::string("1 + 2 3")); + + { + const ALPreprocessor::Result r = run("#define SAY(x) \\ \t\n" + " llOwnerSay(x); \\ \n" + " llSay(0, x)\n" + "default { state_entry() { SAY(\"hi\"); } }\n"); + ensure_equals("blanks after a directive's backslash", flat(r.text), joined); + ensure_equals("said of each", messages(r), + std::string("W 0: backslash and newline separated by space\nW 1: backslash and newline separated by space\n")); + } + { + const ALPreprocessor::Result r = run("// a note \\ \nint shown;\n"); + ensure("a note's backslash and a blank take no line: " + r.text, r.text.find("int shown;") != std::string::npos); + ensure_equals("nothing said", messages(r), std::string()); + } + } } diff --git a/indra/newview/skins/default/xui/en/strings.xml b/indra/newview/skins/default/xui/en/strings.xml index 112ace8254..9b0e88b713 100644 --- a/indra/newview/skins/default/xui/en/strings.xml +++ b/indra/newview/skins/default/xui/en/strings.xml @@ -567,6 +567,7 @@ If you feel this is an error, please contact support@secondlife.com expected a value in preprocessor expression #if with no expression #if without #endif at the end of the file + backslash and newline separated by space integer constant '[1]' is too large for its type '[1]' is not a character constant '__VA_ARGS__' cannot be used as a macro name From beb0d73393e19b7711814575b975748e43872c3f Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 16:56:07 -0400 Subject: [PATCH 15/41] NexiiLSL's preprocessor use is golden, and every golden file runs with CRLF too Every file of NexiiLSL a directive is in, and the one they include besides, copied as they are under its licence, with a golden script for each that calls every macro it defines -- the 28 the repository never uses among them -- and remote-linkset-data.lsl twice, its #ifdefs on and off. The expected texts agree with clang's preprocessor given the same type macros, but for strings over several lines, which come out with \n where their newlines were, as Firestorm writes them. Every golden file and its includes is run again with its lines ended in CRLF and in a lone CR, as a Windows checkout or an old Mac would end them, and must come out the same and say nothing: what NexiiLSL's texture.lsl did not, read from a disk where Git for Windows had ended its lines in CRLF. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/tests/alpreprocessor_test.cpp | 128 ++- .../preprocessor/include/NexiiLSL/LICENSE | 25 + .../preprocessor/include/NexiiLSL/app-uri.lsl | 10 + .../include/NexiiLSL/damage-types.lsl | 880 +++++++++++++++++ .../include/NexiiLSL/inverse-kinematics.lsl | 62 ++ .../preprocessor/include/NexiiLSL/linkset.lsl | 199 ++++ .../include/NexiiLSL/remote-linkset-data.lsl | 104 +++ .../NexiiLSL/scripts/rez-queue-check.lsl | 251 +++++ .../include/NexiiLSL/scripts/roundabout.lsl | 244 +++++ .../include/NexiiLSL/scripts/sync-lsd.lsl | 632 +++++++++++++ .../include/NexiiLSL/string-compression.lsl | 142 +++ .../preprocessor/include/NexiiLSL/texture.lsl | 82 ++ .../preprocessor/include/NexiiLSL/time.lsl | 107 +++ .../include/NexiiLSL/utilities.lsl | 113 +++ .../preprocessor/test_nexiilsl_app_uri.lsl | 22 + .../test_nexiilsl_app_uri_expected.lsl | 32 + .../test_nexiilsl_damage_types.lsl | 4 + .../test_nexiilsl_damage_types_expected.lsl | 884 ++++++++++++++++++ .../test_nexiilsl_inverse_kinematics.lsl | 3 + ...t_nexiilsl_inverse_kinematics_expected.lsl | 65 ++ .../preprocessor/test_nexiilsl_linkset.lsl | 27 + .../test_nexiilsl_linkset_expected.lsl | 220 +++++ .../test_nexiilsl_remote_linkset_data.lsl | 6 + ...test_nexiilsl_remote_linkset_data_bare.lsl | 3 + ...ilsl_remote_linkset_data_bare_expected.lsl | 107 +++ ..._nexiilsl_remote_linkset_data_expected.lsl | 110 +++ .../test_nexiilsl_rez_queue_check.lsl | 3 + ...test_nexiilsl_rez_queue_check_expected.lsl | 264 ++++++ .../preprocessor/test_nexiilsl_roundabout.lsl | 3 + .../test_nexiilsl_roundabout_expected.lsl | 354 +++++++ .../test_nexiilsl_string_compression.lsl | 18 + ...t_nexiilsl_string_compression_expected.lsl | 272 ++++++ .../preprocessor/test_nexiilsl_sync_lsd.lsl | 12 + .../test_nexiilsl_sync_lsd_expected.lsl | 644 +++++++++++++ .../preprocessor/test_nexiilsl_texture.lsl | 23 + .../test_nexiilsl_texture_expected.lsl | 104 +++ .../preprocessor/test_nexiilsl_utilities.lsl | 12 + .../test_nexiilsl_utilities_expected.lsl | 125 +++ 38 files changed, 6264 insertions(+), 32 deletions(-) create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/LICENSE create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/app-uri.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/damage-types.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/inverse-kinematics.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/linkset.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/remote-linkset-data.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/rez-queue-check.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/roundabout.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/sync-lsd.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/string-compression.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/texture.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/time.lsl create mode 100644 indra/alscript/tests/preprocessor/include/NexiiLSL/utilities.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_app_uri.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_app_uri_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_damage_types.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_damage_types_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_linkset.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_linkset_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_roundabout.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_roundabout_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_string_compression.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_string_compression_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_texture.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_texture_expected.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_utilities.lsl create mode 100644 indra/alscript/tests/preprocessor/test_nexiilsl_utilities_expected.lsl diff --git a/indra/alscript/tests/alpreprocessor_test.cpp b/indra/alscript/tests/alpreprocessor_test.cpp index ab086b1b08..236ab2b683 100644 --- a/indra/alscript/tests/alpreprocessor_test.cpp +++ b/indra/alscript/tests/alpreprocessor_test.cpp @@ -95,6 +95,72 @@ namespace tut return out; } + static std::string readFile(const std::filesystem::path& path) + { + llifstream in(path, std::ios::binary); + std::stringstream buffer; + buffer << in.rdbuf(); + return buffer.str(); + } + + // Every newline in a text made `ending`. + static std::string ended(const std::string& text, std::string_view ending) + { + if (ending == "\n") + { + return text; + } + std::string out; + out.reserve(text.size() + text.size() / 16); + for (char c : text) + { + if (c == '\n') + { + out += ending; + } + else + { + out += c; + } + } + return out; + } + + // A golden file under tests/preprocessor/ run as the golden test runs + // it -- its includes from include/, the transforms its first line asks + // for -- with its text and its includes' ending their lines in + // `ending`. + ALPreprocessor::Result runGolden(const std::filesystem::path& file, std::string_view ending) + { + namespace fs = std::filesystem; + const fs::path dir = file.parent_path(); + const std::string source = readFile(file); + const bool lua = file.extension() == ".luau"; + ALPreprocessor::Options o = options(lua); + o.fileName = file.filename().string(); + o.resolve = [dir, ending](const ALPreprocessor::Ask& ask, ALPreprocessor::Include& out) { + // A require names its module without the extension. + for (const char* ext : { "", ".luau", ".lsl" }) + { + const fs::path path = dir / "include" / (ask.name + ext); + if (fs::exists(path)) + { + out.text = ended(readFile(path), ending); + out.name = path.filename().string(); + out.path = out.name; + return ALPreprocessor::Found::Yes; + } + } + return ALPreprocessor::Found::No; + }; + // The first line may ask for the transforms. + const std::string first = source.substr(0, source.find('\n')); + o.switches = first.find("switches") != std::string::npos; + o.lazyLists = first.find("lazylists") != std::string::npos; + o.compress = first.find("compress") != std::string::npos; + return ALPreprocessor::run(ended(source, ending), o); + } + static std::string messages(const ALPreprocessor::Result& r) { std::string out; @@ -596,37 +662,7 @@ namespace tut } const std::string stem = entry.path().stem().string(); const fs::path expected = dir / (stem + "_expected" + entry.path().extension().string()); - const auto read = [](const fs::path& path) { - llifstream in(path, std::ios::binary); - std::stringstream buffer; - buffer << in.rdbuf(); - return buffer.str(); - }; - const std::string source = read(entry.path()); - const bool lua = entry.path().extension() == ".luau"; - ALPreprocessor::Options o = options(lua); - o.fileName = name; - o.resolve = [&](const ALPreprocessor::Ask& ask, ALPreprocessor::Include& out) { - // A require names its module without the extension. - for (const char* ext : { "", ".luau", ".lsl" }) - { - const fs::path path = dir / "include" / (ask.name + ext); - if (fs::exists(path)) - { - out.text = read(path); - out.name = path.filename().string(); - out.path = out.name; - return ALPreprocessor::Found::Yes; - } - } - return ALPreprocessor::Found::No; - }; - // The first line may ask for the transforms. - const std::string first = source.substr(0, source.find('\n')); - o.switches = first.find("switches") != std::string::npos; - o.lazyLists = first.find("lazylists") != std::string::npos; - o.compress = first.find("compress") != std::string::npos; - ALPreprocessor::Result r = ALPreprocessor::run(source, o); + ALPreprocessor::Result r = runGolden(entry.path(), "\n"); ensure_equals("problems in " + name, messages(r), std::string()); // With AL_PREPROCESSOR_WRITE_EXPECTED set, the run writes the // expected files instead of checking them: for a new golden @@ -639,7 +675,7 @@ namespace tut continue; } ensure("expected file for " + name, fs::exists(expected)); - ensure_equals("output of " + name, r.text, read(expected)); + ensure_equals("output of " + name, r.text, readFile(expected)); ++checked; } ensure("some golden files", checked > 0 || std::getenv("AL_PREPROCESSOR_WRITE_EXPECTED")); @@ -1631,4 +1667,32 @@ namespace tut ensure_equals("an include with CRLF said nothing", messages(r), std::string()); ensure("and its macro goes on over its lines: " + r.text, r.text.find("print(1)") != std::string::npos); } + + template<> template<> + void alpreprocessor_object::test<43>() + { + set_test_name("every golden file and its includes, their lines ended in CRLF or in a lone CR as a Windows checkout or an old Mac would, come out as their expected text, saying nothing"); + namespace fs = std::filesystem; + const fs::path dir = fsyspath(AL_ALSCRIPT_TEST_DIR) / "preprocessor"; + S32 checked = 0; + for (const fs::directory_entry& entry : fs::directory_iterator(dir)) + { + const std::string name = entry.path().filename().string(); + if (name.compare(0, 5, "test_") != 0 || name.find("_expected") != std::string::npos) + { + continue; + } + const fs::path expected = dir / (entry.path().stem().string() + "_expected" + entry.path().extension().string()); + const std::string want = readFile(expected); + for (const char* ending : { "\r\n", "\r" }) + { + const std::string said = ending[1] ? " with CRLF" : " with a lone CR"; + ALPreprocessor::Result r = runGolden(entry.path(), ending); + ensure_equals("problems in " + name + said, messages(r), std::string()); + ensure_equals("output of " + name + said, r.text, want); + } + ++checked; + } + ensure("some golden files", checked > 0); + } } diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/LICENSE b/indra/alscript/tests/preprocessor/include/NexiiLSL/LICENSE new file mode 100644 index 0000000000..e28cfad8e2 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/LICENSE @@ -0,0 +1,25 @@ +The files under include/NexiiLSL/ are from https://github.com/Martin-Pitt/NexiiLSL +at 3918b11edd3d0ba47ed4048e14f0daf4026f9b00, copied as they are, under the licence +below: every file of it that a directive is in, and time.lsl, which one includes. + +MIT License + +Copyright (c) 2026 Martin Pitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/app-uri.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/app-uri.lsl new file mode 100644 index 0000000000..a97688aff1 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/app-uri.lsl @@ -0,0 +1,10 @@ +#define agentURI(agent) "secondlife:///app/agent/" + string(agent) + "/inspect" +#define agentAboutURI(agent) "secondlife:///app/agent/" + string(agent) + "/about" +#define regionURI(region) "secondlife:///app/region/" + llEscapeURL(region) +#define objectURI(object) "secondlife:///app/objectim/" + string(object) + "?name=" + llEscapeURL(llKey2Name(object)) + "&owner=" + (string)llGetOwnerKey(object) +#define fullObjectURI(object, name, owner) "secondlife:///app/objectim/" + string(object) + "?name=" + llEscapeURL(name) + "&owner=" + string(owner) +#define groupURI(group) "secondlife:///app/group/" + string(group) + "/inspect" +#define groupAboutURI(group) "secondlife:///app/group/" + string(group) + "/about" +#define experienceURI(experience) "secondlife:///app/experience/" + string(experience) + "/profile" +#define chatURI(channel, message) "secondlife:///app/chat/" + string(channel) + "/" + llEscapeURL(message) +#define wikiLink(link, label) "[" + link + " " + label + "]" diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/damage-types.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/damage-types.lsl new file mode 100644 index 0000000000..733e0e4c2a --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/damage-types.lsl @@ -0,0 +1,880 @@ +#define DAMAGE_TYPE_MEDICAL 100 +#define DAMAGE_TYPE_REPAIR 101 +#define DAMAGE_TYPE_EXPLOSIVE 102 +#define DAMAGE_TYPE_CRUSHING 103 +#define DAMAGE_TYPE_ANTI_ARMOR 104 +#define DAMAGE_TYPE_SUFFOCATION 105 +#define DAMAGE_TYPE_REDEPLOY 106 + +// Returns name of the constant that identifies the damage type +string DamageTypeAsConstant(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "DAMAGE_TYPE_IMPACT"; + if(type == DAMAGE_TYPE_GENERIC) return "DAMAGE_TYPE_GENERIC"; + if(type == DAMAGE_TYPE_ACID) return "DAMAGE_TYPE_ACID"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "DAMAGE_TYPE_BLUDGEONING"; + if(type == DAMAGE_TYPE_COLD) return "DAMAGE_TYPE_COLD"; + if(type == DAMAGE_TYPE_ELECTRIC) return "DAMAGE_TYPE_ELECTRIC"; + if(type == DAMAGE_TYPE_FIRE) return "DAMAGE_TYPE_FIRE"; + if(type == DAMAGE_TYPE_FORCE) return "DAMAGE_TYPE_FORCE"; + if(type == DAMAGE_TYPE_NECROTIC) return "DAMAGE_TYPE_NECROTIC"; + if(type == DAMAGE_TYPE_PIERCING) return "DAMAGE_TYPE_PIERCING"; + if(type == DAMAGE_TYPE_POISON) return "DAMAGE_TYPE_POISON"; + if(type == DAMAGE_TYPE_PSYCHIC) return "DAMAGE_TYPE_PSYCHIC"; + if(type == DAMAGE_TYPE_RADIANT) return "DAMAGE_TYPE_RADIANT"; + if(type == DAMAGE_TYPE_SLASHING) return "DAMAGE_TYPE_SLASHING"; + if(type == DAMAGE_TYPE_SONIC) return "DAMAGE_TYPE_SONIC"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "DAMAGE_TYPE_EMOTIONAL"; + if(type == DAMAGE_TYPE_MEDICAL) return "DAMAGE_TYPE_MEDICAL"; + if(type == DAMAGE_TYPE_REPAIR) return "DAMAGE_TYPE_REPAIR"; + if(type == DAMAGE_TYPE_EXPLOSIVE) return "DAMAGE_TYPE_EXPLOSIVE"; + if(type == DAMAGE_TYPE_CRUSHING) return "DAMAGE_TYPE_CRUSHING"; + if(type == DAMAGE_TYPE_ANTI_ARMOR) return "DAMAGE_TYPE_ANTI_ARMOR"; + if(type == DAMAGE_TYPE_SUFFOCATION) return "DAMAGE_TYPE_SUFFOCATION"; + if(type == DAMAGE_TYPE_REDEPLOY) return "DAMAGE_TYPE_REDEPLOY"; + return (string)type; +} + +// Returns damage type as a noun for use in sentences to describe the damage type +string DamageTypeAsNoun(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impact"; + if(type == DAMAGE_TYPE_GENERIC) return "generic"; + if(type == DAMAGE_TYPE_ACID) return "acid"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoning"; + if(type == DAMAGE_TYPE_COLD) return "cold"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electric"; + if(type == DAMAGE_TYPE_FIRE) return "fire"; + if(type == DAMAGE_TYPE_FORCE) return "force"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotic"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "piercing"; + if(type == DAMAGE_TYPE_POISON) return "poison"; + if(type == DAMAGE_TYPE_PSYCHIC) return "psychic"; + if(type == DAMAGE_TYPE_RADIANT) return "radiant"; + if(type == DAMAGE_TYPE_SLASHING) return "slashing"; + if(type == DAMAGE_TYPE_SONIC) return "sonic"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "emotional"; + if(type == DAMAGE_TYPE_MEDICAL) return "medical"; + if(type == DAMAGE_TYPE_REPAIR) return "repair"; + if(type == DAMAGE_TYPE_EXPLOSIVE) return "explosive"; + if(type == DAMAGE_TYPE_CRUSHING) return "crushing"; + if(type == DAMAGE_TYPE_ANTI_ARMOR) return "anti-armor"; + if(type == DAMAGE_TYPE_SUFFOCATION) return "suffocation"; + if(type == DAMAGE_TYPE_REDEPLOY) return "redeploy"; + return "unknown"; +} + +// Returns damage type as a verb for use in sentences as how it was applied to a target +string DamageTypeAsVerb(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impacted"; + if(type == DAMAGE_TYPE_GENERIC) return "damaged"; + if(type == DAMAGE_TYPE_ACID) return "corroded"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoned"; + if(type == DAMAGE_TYPE_COLD) return "frozen"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electrocuted"; + if(type == DAMAGE_TYPE_FIRE) return "burnt"; + if(type == DAMAGE_TYPE_FORCE) return "smashed"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotised"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "pierced"; + if(type == DAMAGE_TYPE_POISON) return "poisoned"; + if(type == DAMAGE_TYPE_PSYCHIC) return "assaulted"; + if(type == DAMAGE_TYPE_RADIANT) return "raptured"; + if(type == DAMAGE_TYPE_SLASHING) return "slashed"; + if(type == DAMAGE_TYPE_SONIC) return "ruptured"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "traumatised"; + if(type == DAMAGE_TYPE_MEDICAL) return "healed"; + if(type == DAMAGE_TYPE_REPAIR) return "repaired"; + if(type == DAMAGE_TYPE_EXPLOSIVE) return "exploded"; + if(type == DAMAGE_TYPE_CRUSHING) return "crushed"; + if(type == DAMAGE_TYPE_ANTI_ARMOR) return "punched through"; + if(type == DAMAGE_TYPE_SUFFOCATION) return "suffocated"; + if(type == DAMAGE_TYPE_REDEPLOY) return "redeployed"; + return "damaged"; +} + +// Returns PRIM_TEXTURE params for rendering the damage type as an icon +list DamageTypeAsIcon(integer type) +{ + vector offset; integer variant = (integer)llFrand(32.0); + if(type == DAMAGE_TYPE_IMPACT) offset = ; + else if(type == DAMAGE_TYPE_GENERIC) offset = <4, 0, 0>; + else if(type == DAMAGE_TYPE_ACID) offset = <5, 0, 0>; + else if(type == DAMAGE_TYPE_BLUDGEONING) offset = <6, 0, 0>; + else if(type == DAMAGE_TYPE_COLD) offset = <7, 0, 0>; + else if(type == DAMAGE_TYPE_ELECTRIC) offset = <8, 0, 0>; + else if(type == DAMAGE_TYPE_FIRE) offset = <9, 0, 0>; + else if(type == DAMAGE_TYPE_FORCE) offset = <10, 0, 0>; + else if(type == DAMAGE_TYPE_NECROTIC) offset = <11, 0, 0>; + else if(type == DAMAGE_TYPE_PIERCING) offset = <12, 0, 0>; + else if(type == DAMAGE_TYPE_POISON) offset = <13, 0, 0>; + else if(type == DAMAGE_TYPE_PSYCHIC) offset = <14, 0, 0>; + else if(type == DAMAGE_TYPE_RADIANT) offset = <15, 0, 0>; + else if(type == DAMAGE_TYPE_SLASHING) offset = <0, 1, 0>; + else if(type == DAMAGE_TYPE_SONIC) offset = <1 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_EMOTIONAL) offset = <3, 1, 0>; + else if(type == DAMAGE_TYPE_MEDICAL) offset = <4 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_REPAIR) offset = <6 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_EXPLOSIVE) offset = <8, 1, 0>; + else if(type == DAMAGE_TYPE_CRUSHING) offset = <9, 1, 0>; + else if(type == DAMAGE_TYPE_ANTI_ARMOR) offset = <10, 1, 0>; + else if(type == DAMAGE_TYPE_SUFFOCATION) offset = <12, 1, 0>; + else if(type == DAMAGE_TYPE_REDEPLOY) offset = <8, 3, 0>; + + // These are not damage types but additional icons related to combat + else if(type == -100) offset = <0, 3, 0>; // Death + else if(type == -101) offset = <1, 3, 0>; // Enemy Death + else if(type == -102) offset = <2, 3, 0>; // Friendly Death + else if(type == -103) offset = <3, 3, 0>; // Object Death + else if(type == -104) offset = <4, 3, 0>; // Vehicle Death + else if(type == -105) offset = <5, 3, 0>; // Aircraft Death + else if(type == -106) offset = <6, 3, 0>; // Unconscious/Downed + else if(type == -107) offset = <7, 3, 0>; // Revive Requested + else if(type == -108) offset = <8, 3, 0>; // Being Revived + + /* + Potential other icons: + - Respawn, Redeploy + - Mech Death + - Shield Hit, Armor Hit, Critical Hit, EMP Hit + - Critical Hit, Enemy Critical Hit, Friendly Critical Hit + - Blocked Hit, Enemy Blocked Hit, Friendly Blocked Hit + - Missed Hit, Enemy Missed Hit, Friendly Missed Hit + - Civilian Down, Enemy Down, Friendly Down + - Kill Assist + - Headshot + - Target Locked, Target Lost + - Out of Ammo, Low Ammo, Reloading + - Overheated, Jammed, Hacked, Disarmed, Suppressed, Spotted, Invisible + - Invulnerable, Vulnerable + - Buffed, Debuffed + */ + + return [ + "2d053029-7baa-104d-fd8e-34b2112b4b78", + <128./2048., 128./512., 0>, + <(offset.x - 7.5) / 16, (1.5 - offset.y) / 4, 0>, + 0 + ]; +} + +string DamageTypeAsReason(integer type, key owner, key target) +{ + // Picks a uniformly random entry from a list. Avoids the modulo bias + // of llFrand(64.0) % N and means you never have to update counts when + // adding/removing lines. + #define PICK(l) llList2String(l, (integer)llFrand((float)llGetListLength(l))) + + // ============================================================ + // ATTACKER vs VICTIM (owner != target) + // ============================================================ + #define FRIENDLY_FIRE [\ + "OWNER confirmed TARGET as 'probably hostile'",\ + "OWNER eliminated TARGET with outstanding target identification skills",\ + "OWNER won the team's internal conflict against TARGET",\ + "OWNER revealed to TARGET that the IFF system was decorative",\ + "OWNER engaged the nearest available lifeform: TARGET",\ + "OWNER delivered allied fire to TARGET. Emphasis on allied",\ + "OWNER mistook TARGET for a tactical opportunity",\ + "OWNER killed TARGET with an aggressive interpretation of teamwork",\ + "OWNER secured a friendly kill on TARGET. Mission unclear",\ + "OWNER taught TARGET not to stand between a squadmate and bad decisions",\ + "OWNER demonstrated why positive identification matters. TARGET objected briefly",\ + "OWNER greeted TARGET with extremely friendly burst fire",\ + "OWNER's suppressive fire caught TARGET non-suppressed",\ + "OWNER showed TARGET a safety selector permanently set to optimism",\ + "OWNER found TARGET faster than the enemy did",\ + "OWNER introduced TARGET to friendly fire",\ + "OWNER secured an enemy-adjacent kill on TARGET",\ + "OWNER promoted TARGET to hostile",\ + "OWNER carried the enemy team by killing TARGET",\ + "OWNER won an internal faction dispute against TARGET",\ + "OWNER treated TARGET like hostile armor",\ + "OWNER killed TARGET with unwavering confidence and limited accuracy",\ + "OWNER's line of fire met TARGET head-on",\ + "OWNER killed TARGET after TARGET challenged basic firearm safety",\ + "OWNER defended their muzzle space from TARGET",\ + "OWNER shot TARGET, who appeared suddenly and unhelpfully",\ + "OWNER's burst fire awarded TARGET a prize for crossing it",\ + "OWNER eliminated TARGET, who selected poor timing as a lifestyle",\ + "OWNER hit TARGET with rounds clearly intended for somebody else",\ + "OWNER's bullets met TARGET halfway",\ + "OWNER solved a tactical problem that TARGET inserted themselves into",\ + "OWNER killed TARGET after TARGET performed unauthorized bullet interception",\ + "OWNER was already shooting exactly where TARGET peeked",\ + "OWNER dropped TARGET, who entered the doorway at maximum inconvenience",\ + "OWNER engaged the enemy while TARGET was hugging it",\ + "OWNER's suppressive fire was suppressed by TARGET's positioning",\ + "OWNER demonstrated why you don't cross the firing lane. TARGET volunteered",\ + "OWNER secured a friendly kill after TARGET sprinted through active gunfire",\ + "OWNER penalized TARGET for performing close-quarters teamwork incorrectly",\ + "OWNER shot TARGET, who materialized directly in the optic picture",\ + "OWNER failed a trigger discipline test administered by TARGET at point-blank range",\ + "OWNER accidentally fulfilled TARGET's request for covering fire",\ + "OWNER charged TARGET full price for using the firing lane as a shortcut",\ + "OWNER killed TARGET, who confused suppressive fire with navigational guidance",\ + "OWNER confirmed TARGET located the line of fire by standing in it",\ + "OWNER eliminated TARGET after TARGET ignored several visible warning signs",\ + "OWNER intervened in TARGET's attempt at advanced teamwork without situational awareness",\ + "OWNER shot TARGET, who committed aggressively to being in the way",\ + "OWNER's ballistic calculations gained an unexpected variable: TARGET",\ + "OWNER's aim was fine. TARGET's pathfinding was not",\ + "OWNER cleared TARGET out from between themselves and success",\ + "OWNER killed TARGET after TARGET made themselves tactically unavoidable"\ + ] + #define IMPACT [\ + "OWNER knocked TARGET into a wall",\ + "OWNER slammed TARGET into cover",\ + "OWNER sent TARGET crashing into terrain",\ + "OWNER introduced TARGET to the ground at speed",\ + "OWNER forced TARGET into a fatal collision",\ + "OWNER's fire cancelled TARGET's momentum permanently",\ + "OWNER knocked TARGET off balance and into solid concrete",\ + "OWNER pressured TARGET into pancaking against the environment",\ + "OWNER turned gravity against TARGET",\ + "OWNER arranged a high-speed argument between TARGET and physics. Physics won"\ + ] + #define GENERIC [\ + "OWNER killed TARGET",\ + "OWNER eliminated TARGET",\ + "OWNER took down TARGET",\ + "OWNER neutralized TARGET",\ + "OWNER removed TARGET from the battlefield",\ + "OWNER put TARGET down",\ + "OWNER dropped TARGET",\ + "OWNER defeated TARGET",\ + "OWNER finished TARGET",\ + "OWNER took TARGET out of the fight"\ + ] + #define ACID [\ + "OWNER dissolved TARGET with corrosives",\ + "OWNER melted TARGET with a chemical attack",\ + "OWNER splashed TARGET with lethal acid",\ + "OWNER delivered a corrosive payload directly to TARGET",\ + "OWNER reduced TARGET's gear to sludge",\ + "OWNER chemically erased TARGET",\ + "OWNER turned chemistry into a weapon against TARGET",\ + "OWNER's acid attack proved too much for TARGET",\ + "OWNER corroded TARGET beyond repair",\ + "OWNER liquefied TARGET"\ + ] + #define BLUDGEONING [\ + "OWNER beat TARGET down",\ + "OWNER crushed TARGET with blunt force",\ + "OWNER smashed TARGET into submission",\ + "OWNER landed a fatal blunt hit on TARGET",\ + "OWNER battered TARGET into the dirt",\ + "OWNER flattened TARGET",\ + "OWNER hammered TARGET",\ + "OWNER's heavy impact ended TARGET",\ + "OWNER delivered overwhelming blunt trauma to TARGET",\ + "OWNER broke TARGET with a single strike"\ + ] + #define COLD [\ + "OWNER froze TARGET solid",\ + "OWNER's cryogenic attack claimed TARGET",\ + "OWNER chilled TARGET beyond recovery",\ + "OWNER iced TARGET over",\ + "OWNER turned TARGET into a frozen casualty",\ + "OWNER subjected TARGET to lethal cold exposure",\ + "OWNER locked TARGET down with extreme cold",\ + "OWNER left TARGET frozen mid-fight",\ + "OWNER weaponized winter against TARGET",\ + "OWNER won the heat war against TARGET"\ + ] + #define ELECTRIC [\ + "OWNER electrocuted TARGET",\ + "OWNER shocked TARGET to death",\ + "OWNER overloaded TARGET with electricity",\ + "OWNER delivered lethal voltage to TARGET",\ + "OWNER lit TARGET up with electrical discharge",\ + "OWNER shut down TARGET's nervous system",\ + "OWNER short-circuited TARGET",\ + "OWNER applied a high-voltage solution to TARGET",\ + "OWNER fried TARGET",\ + "OWNER made TARGET spark out"\ + ] + #define FIRE [\ + "OWNER burned TARGET alive",\ + "OWNER incinerated TARGET",\ + "OWNER engulfed TARGET in flames",\ + "OWNER cooked TARGET with incendiaries",\ + "OWNER reduced TARGET to ashes",\ + "OWNER set TARGET on fire. Permanently",\ + "OWNER scorched TARGET off the battlefield",\ + "OWNER won the thermal exchange with TARGET",\ + "OWNER roasted TARGET",\ + "OWNER's flames consumed TARGET"\ + ] + #define FORCE [\ + "OWNER blasted TARGET apart",\ + "OWNER launched TARGET",\ + "OWNER overwhelmed TARGET with raw force",\ + "OWNER hit TARGET with a fatal shockwave",\ + "OWNER hit TARGET with devastating force",\ + "OWNER's impact proved unsurvivable for TARGET",\ + "OWNER violently displaced TARGET",\ + "OWNER freight-trained TARGET",\ + "OWNER crushed TARGET with concussive power",\ + "OWNER blew TARGET clean off their position"\ + ] + #define NECROTIC [\ + "OWNER drained the life from TARGET",\ + "OWNER withered TARGET away",\ + "OWNER consumed TARGET's vitality",\ + "OWNER accelerated TARGET's decay considerably",\ + "OWNER left TARGET biologically ruined",\ + "OWNER's lethal touch faded TARGET out",\ + "OWNER reduced TARGET to a husk",\ + "OWNER outlasted TARGET's life force",\ + "OWNER brought slow death to TARGET",\ + "OWNER's necrotic damage claimed TARGET"\ + ] + #define PIERCING [\ + "OWNER riddled TARGET with bullets",\ + "OWNER cut TARGET down with rifle fire",\ + "OWNER stitched TARGET with automatic fire",\ + "OWNER put a burst into TARGET",\ + "OWNER perforated TARGET with incoming rounds",\ + "OWNER drilled TARGET with gunfire",\ + "OWNER landed center-mass hits on TARGET",\ + "OWNER won the firefight against TARGET",\ + "OWNER filled TARGET with lead",\ + "OWNER ventilated TARGET"\ + ] + #define POISON [\ + "OWNER poisoned TARGET",\ + "OWNER's toxins claimed TARGET",\ + "OWNER contaminated TARGET fatally",\ + "OWNER chemically deteriorated TARGET",\ + "OWNER dosed TARGET with lethal poison",\ + "OWNER convinced TARGET's body to give up",\ + "OWNER weaponized toxins against TARGET",\ + "OWNER's poison ran its full course on TARGET",\ + "OWNER delivered a toxic end to TARGET",\ + "OWNER gave TARGET a fatal chemical exposure"\ + ] + #define PSYCHIC [\ + "OWNER shattered TARGET's mind",\ + "OWNER caused TARGET's complete mental collapse",\ + "OWNER overwhelmed TARGET psychologically",\ + "OWNER out-thought TARGET fatally",\ + "OWNER broke TARGET mentally",\ + "OWNER won the cognitive battle against TARGET",\ + "OWNER overloaded TARGET's mind",\ + "OWNER psychologically neutralized TARGET",\ + "OWNER destabilized TARGET completely",\ + "OWNER gave TARGET more than TARGET could process"\ + ] + #define RADIANT [\ + "OWNER overwhelmed TARGET with radiant energy",\ + "OWNER burned TARGET with radiation",\ + "OWNER blasted TARGET with destructive light",\ + "OWNER made TARGET glow. Briefly",\ + "OWNER irradiated TARGET fatally",\ + "OWNER's energy discharge claimed TARGET",\ + "OWNER seared TARGET with pure energy",\ + "OWNER outshone TARGET fatally",\ + "OWNER exposed TARGET to lethal radiation",\ + "OWNER annihilated TARGET with raw energy"\ + ] + #define SLASHING [\ + "OWNER cut TARGET down",\ + "OWNER carved TARGET apart",\ + "OWNER opened TARGET up",\ + "OWNER's blade met TARGET. Once was enough",\ + "OWNER sliced through TARGET",\ + "OWNER left TARGET with fatal cuts",\ + "OWNER shredded TARGET",\ + "OWNER tore TARGET apart",\ + "OWNER made short work of TARGET",\ + "OWNER showed TARGET the sharp end"\ + ] + #define SONIC [\ + "OWNER blasted TARGET with sonic force",\ + "OWNER collapsed TARGET's senses",\ + "OWNER overwhelmed TARGET with pressure waves",\ + "OWNER's sonic blast proved fatal for TARGET",\ + "OWNER deafened TARGET permanently",\ + "OWNER rocked TARGET with acoustic weaponry",\ + "OWNER weaponized sound against TARGET",\ + "OWNER won the volume war against TARGET",\ + "OWNER shattered TARGET with sonic energy",\ + "OWNER concussed TARGET with a sonic assault"\ + ] + #define EMOTIONAL [\ + "OWNER emotionally devastated TARGET",\ + "OWNER collapsed TARGET's morale entirely",\ + "OWNER broke TARGET's spirit",\ + "OWNER dealt critical emotional damage to TARGET",\ + "OWNER crushed TARGET's confidence",\ + "OWNER won the psychological war against TARGET",\ + "OWNER destroyed TARGET's morale",\ + "OWNER applied pressure TARGET never recovered from",\ + "OWNER left TARGET mentally defeated",\ + "OWNER made sure TARGET took the loss personally"\ + ] + #define MEDICAL [\ + "OWNER's treatment killed TARGET",\ + "OWNER's medical intervention proved fatal to TARGET",\ + "OWNER flatlined TARGET during treatment",\ + "OWNER gave TARGET one treatment too many",\ + "OWNER's battlefield medicine failed TARGET catastrophically",\ + "OWNER's bedside manner killed TARGET",\ + "OWNER healed TARGET into the afterlife",\ + "OWNER's medkit betrayed TARGET's trust",\ + "OWNER's treatment proved terminal for TARGET",\ + "OWNER medically neutralized TARGET"\ + ] + #define REPAIR [\ + "OWNER repaired TARGET into catastrophic failure",\ + "OWNER's maintenance proved unsurvivable for TARGET",\ + "OWNER's field repairs ended TARGET",\ + "OWNER performed rapid unscheduled disassembly on TARGET",\ + "OWNER pushed TARGET beyond operational limits",\ + "OWNER's repair attempt finished TARGET off",\ + "OWNER turned maintenance into a kill",\ + "OWNER's adjustments collapsed TARGET's systems",\ + "OWNER overclocked TARGET to destruction",\ + "OWNER rendered TARGET permanently nonfunctional"\ + ] + #define EXPLOSIVE [\ + "OWNER blew TARGET apart",\ + "OWNER caught TARGET in the blast radius",\ + "OWNER introduced TARGET to high explosives",\ + "OWNER fed TARGET a grenade",\ + "OWNER detonated TARGET",\ + "OWNER's explosives outran TARGET",\ + "OWNER erased TARGET with a blast",\ + "OWNER's detonation shredded TARGET",\ + "OWNER reduced TARGET to debris",\ + "OWNER's ordnance found TARGET standing too close"\ + ] + #define CRUSHING [\ + "OWNER crushed TARGET",\ + "OWNER flattened TARGET completely",\ + "OWNER compressed TARGET into failure",\ + "OWNER pinned and crushed TARGET",\ + "OWNER turned pressure into a weapon against TARGET",\ + "OWNER folded TARGET like field equipment",\ + "OWNER reduced TARGET to scrap",\ + "OWNER compacted TARGET",\ + "OWNER buried TARGET under overwhelming pressure",\ + "OWNER ran a compression test on TARGET. TARGET failed"\ + ] + #define ANTI_ARMOR [\ + "OWNER punched through TARGET's armor",\ + "OWNER penetrated TARGET with anti-armor fire",\ + "OWNER defeated TARGET with AP rounds",\ + "OWNER proved TARGET's armor was a suggestion",\ + "OWNER cracked TARGET open with anti-vehicle fire",\ + "OWNER's penetrators went straight through TARGET",\ + "OWNER turned TARGET's armor into a liability",\ + "OWNER gutted TARGET with heavy ordnance",\ + "OWNER landed a clean anti-armor kill on TARGET",\ + "OWNER won the armor check against TARGET"\ + ] + #define SUFFOCATION [\ + "OWNER deprived TARGET of oxygen",\ + "OWNER suffocated TARGET",\ + "OWNER left TARGET without breathable air",\ + "OWNER ran TARGET out of air",\ + "OWNER choked the life out of TARGET",\ + "OWNER removed breathing from TARGET's options",\ + "OWNER turned atmosphere into a privilege TARGET didn't have",\ + "OWNER revoked TARGET's oxygen access",\ + "OWNER asphyxiated TARGET",\ + "OWNER reminded TARGET that breathing was mandatory. Too late"\ + ] + + // ============================================================ + // SELF-INFLICTED (owner == target) — TARGET-only phrasing + // ============================================================ + #define IMPACT_SELF [\ + "TARGET punched the ground. The ground punched back",\ + "TARGET tested the structural integrity of a nearby wall",\ + "TARGET tested the structural integrity of a nearby floor",\ + "TARGET tested the structural integrity of a nearby ceiling",\ + "TARGET discovered the upper limit of their movement enhancer",\ + "TARGET achieved terminal mobility",\ + "TARGET mistook momentum for a survival strategy",\ + "TARGET lost a close-quarters engagement with the terrain",\ + "TARGET deployed directly into solid matter",\ + "TARGET accelerated confidently into disaster",\ + "TARGET's movement tech delivered exactly as advertised",\ + "TARGET learned that velocity has consequences",\ + "TARGET attempted advanced maneuvering without adult supervision",\ + "TARGET converted speed into an immediate medical issue",\ + "TARGET overshot the objective by several meters and one life",\ + "TARGET used tactical sprint offensively against themselves",\ + "TARGET was outplayed by basic map geometry",\ + "TARGET trusted the jump pack. The jump pack disagreed",\ + "TARGET discovered an undocumented interaction between boosters and concrete",\ + "TARGET briefly achieved flight before remembering they weren't aviation-capable",\ + "TARGET's landing request was denied by physics",\ + "TARGET found the map boundary emotionally and physically",\ + "TARGET turned mobility equipment into a kinetic weapon against TARGET",\ + "TARGET's combat stim demanded speed; the wall accepted payment",\ + "TARGET exceeded recommended operating velocity",\ + "TARGET weaponized their own momentum",\ + "TARGET slammed into cover with unnecessary enthusiasm",\ + "TARGET's parkour certification has been revoked",\ + "TARGET hit mach jesus and forgot the braking phase",\ + "TARGET went from combatant to projectile",\ + "TARGET speedran the respawn screen",\ + "TARGET demonstrated why boosters come with warning labels",\ + "TARGET challenged Newtonian mechanics and lost unanimously",\ + "TARGET's movement enhancer secured a confirmed kill",\ + "TARGET achieved excellent airtime and poor decision-making",\ + "TARGET discovered a new route directly into the ground",\ + "TARGET attempted vertical gameplay and received horizontal consequences",\ + "TARGET became one with nearby infrastructure"\ + ] + #define GENERIC_SELF [\ + "TARGET eliminated TARGET. Efficient",\ + "TARGET cut out the middleman and killed themselves",\ + "TARGET found the enemy. It was TARGET",\ + "TARGET secured a confirmed kill on TARGET",\ + "TARGET defeated their greatest opponent: TARGET",\ + "TARGET needed no assistance dying",\ + "TARGET took themselves out of the fight",\ + "TARGET saved the enemy some ammunition"\ + ] + #define ACID_SELF [\ + "TARGET dissolved in their own corrosives",\ + "TARGET learned that acid doesn't check IFF",\ + "TARGET became the test subject of their own chemistry experiment",\ + "TARGET stored the acid incorrectly: on themselves",\ + "TARGET handled corrosives with confidence instead of gloves",\ + "TARGET's chemical weapon chose violence locally",\ + "TARGET self-liquefied",\ + "TARGET reduced their own gear, and TARGET, to sludge"\ + ] + #define BLUDGEONING_SELF [\ + "TARGET beat themselves to the punch. Fatally",\ + "TARGET delivered blunt trauma to the nearest available skull: their own",\ + "TARGET lost a fistfight against their own equipment",\ + "TARGET swung hard and connected with TARGET",\ + "TARGET self-administered percussive maintenance",\ + "TARGET was somehow on both ends of the hammer",\ + "TARGET flattened the closest combatant: TARGET",\ + "TARGET hammered themselves into the dirt unassisted"\ + ] + #define COLD_SELF [\ + "TARGET froze themselves solid",\ + "TARGET stress-tested their own cryogenics from the inside",\ + "TARGET achieved self-refrigeration",\ + "TARGET forgot which end of the cryo weapon was the cold one",\ + "TARGET preserved themselves for future generations",\ + "TARGET lost the heat war against TARGET",\ + "TARGET turned themselves into a frozen casualty",\ + "TARGET weaponized winter against the only person in range: TARGET"\ + ] + #define ELECTRIC_SELF [\ + "TARGET electrocuted themselves",\ + "TARGET completed the circuit personally",\ + "TARGET discovered they were the path of least resistance",\ + "TARGET grounded themselves. Permanently",\ + "TARGET conducted a one-person electrical safety demonstration",\ + "TARGET's high-voltage solution solved TARGET",\ + "TARGET short-circuited their own nervous system",\ + "TARGET fried the nearest conductor: TARGET"\ + ] + #define FIRE_SELF [\ + "TARGET self-ignited",\ + "TARGET stood in their own fire and called it warmth",\ + "TARGET deployed incendiaries at extremely personal range",\ + "TARGET became their own thermal signature",\ + "TARGET played with fire and lost custody",\ + "TARGET achieved self-cremation ahead of schedule",\ + "TARGET lost the thermal exchange with TARGET",\ + "TARGET's flames showed no brand loyalty"\ + ] + #define FORCE_SELF [\ + "TARGET blasted themselves apart",\ + "TARGET stood at the business end of their own shockwave",\ + "TARGET applied devastating force in the wrong direction",\ + "TARGET freight-trained TARGET",\ + "TARGET violently displaced themselves",\ + "TARGET's raw power needed a target. TARGET volunteered",\ + "TARGET blew themselves clean off their own position",\ + "TARGET found their own impact unsurvivable"\ + ] + #define NECROTIC_SELF [\ + "TARGET drained their own life force",\ + "TARGET accelerated their own decay considerably",\ + "TARGET reduced themselves to a husk",\ + "TARGET's life force resigned without notice",\ + "TARGET consumed their own vitality. Bold strategy",\ + "TARGET brought slow death to the nearest lifeform: TARGET",\ + "TARGET faded themselves out",\ + "TARGET left TARGET biologically ruined"\ + ] + #define PIERCING_SELF [\ + "TARGET shot themselves",\ + "TARGET checked if the gun was loaded the hard way",\ + "TARGET caught a burst from a familiar rifle: their own",\ + "TARGET lost a firefight with zero other participants",\ + "TARGET demonstrated muzzle awareness by counterexample",\ + "TARGET filled themselves with lead",\ + "TARGET ventilated TARGET",\ + "TARGET landed center-mass hits on the wrong center mass"\ + ] + #define POISON_SELF [\ + "TARGET poisoned themselves",\ + "TARGET taste-tested their own toxins",\ + "TARGET confused the antidote with the dosage",\ + "TARGET's chemical weapon worked exactly once, on TARGET",\ + "TARGET self-administered a lethal dose",\ + "TARGET trusted their own labeling system incorrectly",\ + "TARGET's body gave up after consulting with TARGET",\ + "TARGET delivered a toxic end to TARGET"\ + ] + #define PSYCHIC_SELF [\ + "TARGET thought themselves to death",\ + "TARGET lost an argument inside their own head",\ + "TARGET's mind overloaded itself",\ + "TARGET overthought a survivable situation. Fatally",\ + "TARGET was psychologically neutralized by TARGET",\ + "TARGET won the cognitive battle and also lost it",\ + "TARGET shattered their own mind unassisted",\ + "TARGET gave TARGET more than TARGET could process"\ + ] + #define RADIANT_SELF [\ + "TARGET irradiated themselves",\ + "TARGET stood inside their own energy discharge",\ + "TARGET glowed with self-confidence. Then just glowed",\ + "TARGET ignored the safe operating distance of their own weapon",\ + "TARGET achieved self-illumination. Briefly",\ + "TARGET exposed themselves to lethal radiation. Their own",\ + "TARGET outshone TARGET fatally",\ + "TARGET annihilated the closest energy signature: TARGET"\ + ] + #define SLASHING_SELF [\ + "TARGET cut themselves down",\ + "TARGET lost a knife fight against TARGET",\ + "TARGET found the sharp end of their own blade",\ + "TARGET demonstrated blade safety by counterexample",\ + "TARGET sliced through the nearest combatant: TARGET",\ + "TARGET made short work of themselves",\ + "TARGET opened TARGET up. No assistance required",\ + "TARGET's blade showed no loyalty"\ + ] + #define SONIC_SELF [\ + "TARGET deafened themselves to death",\ + "TARGET stood inside their own pressure wave",\ + "TARGET lost the volume war against TARGET",\ + "TARGET turned the acoustic weapon up to eleven while holding it",\ + "TARGET concussed themselves with their own bass drop",\ + "TARGET's sonic blast didn't discriminate",\ + "TARGET collapsed their own senses",\ + "TARGET weaponized sound against the closest pair of ears: their own"\ + ] + #define EMOTIONAL_SELF [\ + "TARGET's morale collapsed under TARGET",\ + "TARGET lost the psychological war with themselves",\ + "TARGET took the loss personally. From themselves",\ + "TARGET crushed their own confidence fatally",\ + "TARGET broke their own spirit before the enemy could",\ + "TARGET was their own harshest critic. Lethally",\ + "TARGET applied pressure TARGET never recovered from",\ + "TARGET emotionally devastated TARGET"\ + ] + #define MEDICAL_SELF [\ + "TARGET self-medicated into the afterlife",\ + "TARGET's self-treatment proved terminal",\ + "TARGET read the medkit instructions posthumously",\ + "TARGET healed themselves to death",\ + "TARGET trusted their own medical credentials incorrectly",\ + "TARGET flatlined under their own care",\ + "TARGET gave TARGET one treatment too many",\ + "TARGET's bedside manner killed the only patient available: TARGET"\ + ] + #define REPAIR_SELF [\ + "TARGET repaired themselves into catastrophic failure",\ + "TARGET performed rapid unscheduled self-disassembly",\ + "TARGET overclocked themselves to destruction",\ + "TARGET voided their own warranty",\ + "TARGET's self-maintenance proved unsurvivable",\ + "TARGET pushed TARGET beyond operational limits",\ + "TARGET turned self-maintenance into a confirmed kill",\ + "TARGET rendered TARGET permanently nonfunctional"\ + ] + #define EXPLOSIVE_SELF [\ + "TARGET held the grenade slightly too long",\ + "TARGET stood in their own blast radius",\ + "TARGET cooked a grenade past the recommended doneness",\ + "TARGET became the epicenter",\ + "TARGET's ordnance arrived before TARGET left",\ + "TARGET reduced themselves to debris",\ + "TARGET couldn't outrun their own explosives",\ + "TARGET ate their own grenade. Chef's kiss"\ + ] + #define CRUSHING_SELF [\ + "TARGET crushed themselves",\ + "TARGET was flattened by their own equipment",\ + "TARGET ran a compression test on TARGET. Both sides failed",\ + "TARGET parked something heavy on top of themselves",\ + "TARGET folded under pressure of their own making",\ + "TARGET compacted themselves for easy storage",\ + "TARGET reduced TARGET to scrap",\ + "TARGET turned pressure into a weapon against the only one in range: TARGET"\ + ] + #define ANTI_ARMOR_SELF [\ + "TARGET penetrated their own armor",\ + "TARGET proved their own armor was a suggestion",\ + "TARGET tested AP rounds on the nearest armor: their own",\ + "TARGET turned their armor into a liability personally",\ + "TARGET cracked themselves open with anti-vehicle fire",\ + "TARGET won and lost the armor check simultaneously",\ + "TARGET's penetrators went straight through TARGET",\ + "TARGET gutted TARGET with heavy ordnance. Impressive angle"\ + ] + #define SUFFOCATION_SELF [\ + "TARGET forgot breathing was mandatory",\ + "TARGET ran out of air with no help from anyone",\ + "TARGET revoked their own oxygen access",\ + "TARGET held their breath competitively against themselves",\ + "TARGET treated oxygen as optional",\ + "TARGET asphyxiated unassisted",\ + "TARGET removed breathing from their own options",\ + "TARGET turned atmosphere into a privilege TARGET didn't have"\ + ] + #define REDEPLOY [\ + "TARGET redeployed to a new position",\ + "TARGET returned to the deployment queue",\ + "TARGET chose to redeploy",\ + "TARGET cycled back to spawn",\ + "TARGET withdrew for redeployment",\ + "TARGET repositioned via redeploy",\ + "TARGET abandoned the current position",\ + "TARGET respawned elsewhere",\ + "TARGET reset their tactical situation",\ + "TARGET redeployed out of the combat zone",\ + "TARGET took the express route back to spawn",\ + "TARGET performed a tactical respawn",\ + "TARGET sought better life choices via redeploy",\ + "TARGET changed their mind — and location",\ + "TARGET performed tactical disappearance",\ + "TARGET reconsidered their surroundings",\ + "TARGET pressed the redeploy button",\ + "TARGET found a more survivable ZIP code",\ + "TARGET left to spawn somewhere less explosive",\ + "TARGET took a strategic timeout"\ + ] + + string reason; + + // Redeploy is always a voluntary self-action, regardless of who is credited + if(type == DAMAGE_TYPE_REDEPLOY) + { + reason = PICK(REDEPLOY); + } + else if(owner == target) + { + // Self-inflicted: TARGET-only phrasing, no attacker credit + if(type == DAMAGE_TYPE_IMPACT) reason = PICK(IMPACT_SELF); + else if(type == DAMAGE_TYPE_GENERIC) reason = PICK(GENERIC_SELF); + else if(type == DAMAGE_TYPE_ACID) reason = PICK(ACID_SELF); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = PICK(BLUDGEONING_SELF); + else if(type == DAMAGE_TYPE_COLD) reason = PICK(COLD_SELF); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = PICK(ELECTRIC_SELF); + else if(type == DAMAGE_TYPE_FIRE) reason = PICK(FIRE_SELF); + else if(type == DAMAGE_TYPE_FORCE) reason = PICK(FORCE_SELF); + else if(type == DAMAGE_TYPE_NECROTIC) reason = PICK(NECROTIC_SELF); + else if(type == DAMAGE_TYPE_PIERCING) reason = PICK(PIERCING_SELF); + else if(type == DAMAGE_TYPE_POISON) reason = PICK(POISON_SELF); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = PICK(PSYCHIC_SELF); + else if(type == DAMAGE_TYPE_RADIANT) reason = PICK(RADIANT_SELF); + else if(type == DAMAGE_TYPE_SLASHING) reason = PICK(SLASHING_SELF); + else if(type == DAMAGE_TYPE_SONIC) reason = PICK(SONIC_SELF); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = PICK(EMOTIONAL_SELF); + else if(type == DAMAGE_TYPE_MEDICAL) reason = PICK(MEDICAL_SELF); + else if(type == DAMAGE_TYPE_REPAIR) reason = PICK(REPAIR_SELF); + else if(type == DAMAGE_TYPE_EXPLOSIVE) reason = PICK(EXPLOSIVE_SELF); + else if(type == DAMAGE_TYPE_CRUSHING) reason = PICK(CRUSHING_SELF); + else if(type == DAMAGE_TYPE_ANTI_ARMOR) reason = PICK(ANTI_ARMOR_SELF); + else if(type == DAMAGE_TYPE_SUFFOCATION) reason = PICK(SUFFOCATION_SELF); + else if(type == -100) reason = "TARGET team-killed the only teammate within reach: TARGET"; + else reason = "TARGET was killed by TARGET's own " + DamageTypeAsNoun(type) + " damage. Impressive"; + } + else + { + // Attacker vs victim: OWNER-first phrasing + if(type == DAMAGE_TYPE_IMPACT) reason = PICK(IMPACT); + else if(type == DAMAGE_TYPE_GENERIC) reason = PICK(GENERIC); + else if(type == DAMAGE_TYPE_ACID) reason = PICK(ACID); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = PICK(BLUDGEONING); + else if(type == DAMAGE_TYPE_COLD) reason = PICK(COLD); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = PICK(ELECTRIC); + else if(type == DAMAGE_TYPE_FIRE) reason = PICK(FIRE); + else if(type == DAMAGE_TYPE_FORCE) reason = PICK(FORCE); + else if(type == DAMAGE_TYPE_NECROTIC) reason = PICK(NECROTIC); + else if(type == DAMAGE_TYPE_PIERCING) reason = PICK(PIERCING); + else if(type == DAMAGE_TYPE_POISON) reason = PICK(POISON); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = PICK(PSYCHIC); + else if(type == DAMAGE_TYPE_RADIANT) reason = PICK(RADIANT); + else if(type == DAMAGE_TYPE_SLASHING) reason = PICK(SLASHING); + else if(type == DAMAGE_TYPE_SONIC) reason = PICK(SONIC); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = PICK(EMOTIONAL); + else if(type == DAMAGE_TYPE_MEDICAL) reason = PICK(MEDICAL); + else if(type == DAMAGE_TYPE_REPAIR) reason = PICK(REPAIR); + else if(type == DAMAGE_TYPE_EXPLOSIVE) reason = PICK(EXPLOSIVE); + else if(type == DAMAGE_TYPE_CRUSHING) reason = PICK(CRUSHING); + else if(type == DAMAGE_TYPE_ANTI_ARMOR) reason = PICK(ANTI_ARMOR); + else if(type == -100) reason = PICK(FRIENDLY_FIRE); + else if(type == DAMAGE_TYPE_SUFFOCATION) reason = PICK(SUFFOCATION); + else reason = "OWNER killed TARGET with " + DamageTypeAsNoun(type) + " damage"; + } + + reason = llReplaceSubString(reason, "TARGET", "secondlife:///app/agent/" + (string)target + "/inspect", 0); + reason = llReplaceSubString(reason, "OWNER", "secondlife:///app/agent/" + (string)owner + "/inspect", 0); + return reason; +} + + +// Applies anti-armor damage in Combat2, with compatibility fallback to LBA (Listen Based Armor) +DamageArmor(key target, float damage) +{ + // Has Combat2 health, apply ANTI_ARMOR type + if(llGetHealth(target) > 0) + { + llDamage(target, damage, DAMAGE_TYPE_ANTI_ARMOR); + // llOwnerSay("Applied " + (string)damage + " anti-armor damage to " + (string)target); + } + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = integer("0x" + llGetSubString(llMD5String(target, 0), 0, 3)); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + // llOwnerSay("Applied " + (string)damage + " LBA damage to " + (string)target); + } + } +} + +// Alternatively, if you have llDetectedType available, such as from a collision or a sensor, +// use that instead of llGetHealth as you can check for DAMAGEABLE which is more accurate +// as it signifies the object can actually process damage +DamageArmorDetected(integer index, float damage) +{ + key target = llDetectedKey(index); + integer detectedType = llDetectedType(index); + + // Can process damage, so apply ANTI_ARMOR type + if(detectedType & DAMAGEABLE) llDamage(target, damage, DAMAGE_TYPE_ANTI_ARMOR); + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = integer("0x" + llGetSubString(llMD5String(target, 0), 0, 3)); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + } + } +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/inverse-kinematics.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/inverse-kinematics.lsl new file mode 100644 index 0000000000..561280ed6c --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/inverse-kinematics.lsl @@ -0,0 +1,62 @@ + +#define IK_OUT_OF_REACH -1 +#define IK_TOO_CLOSE -2 +#define IK_SUCCESS 1 + +// Simple inverse kinematics via trigonemetry +TrigIK( + vector position, // relative position, only uses x (forward/back) and z (up/down) + float joint0, // First joint length + float joint1, // Second joint length + vector axis, // Axis that the joint rotates on + integer complementary // Whether to use complementary angle on result +) { + position.y = 0; + float distance = llVecMag(position); + + // If we can't reach it, just extend out + if(distance > joint0 + joint1) + { + IKr = IK_OUT_OF_REACH; + IK1 = llEuler2Rot(-axis * llAtan2(position.z, position.x)); + IK2 = ZERO_ROTATION; + return; + } + + float distanceSq = distance * distance; + float joint0Sq = joint0 * joint0; + float joint1Sq = joint1 * joint1; + float x = (joint0Sq - joint1Sq + distanceSq) / (2 * joint0 * distance); + float y = (joint0Sq + joint1Sq - distanceSq) / (2 * joint0 * joint1); + float q0 = llAtan2(position.z, position.x); + + // Too close + if(x > 1 || x < -1 || y > 1 || y < -1) + { + IKr = IK_TOO_CLOSE; + IK1 = llEuler2Rot(-axis *( q0)); + IK2 = ZERO_ROTATION; + return; + } + + // There's two different ways to reach the same point + float q1; float q2; + if(complementary) + { + q1 = q0 - llAcos(x); + q2 = PI - llAcos(y); + } + else + { + q1 = q0 + llAcos(x); + q2 = PI + llAcos(y); + } + + IKr = IK_SUCCESS; + IK1 = llEuler2Rot(-axis * q1); + IK2 = llEuler2Rot(-axis * q2); +} +// Return values: +integer IK; // IK results; IK_OUT_OF_REACH, IK_TOO_CLOSE, IK_SUCCESS +rotation IK1; // First joint rotation +rotation IK2; // Second joint rotation diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/linkset.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/linkset.lsl new file mode 100644 index 0000000000..a312d9de38 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/linkset.lsl @@ -0,0 +1,199 @@ +//////////////////////////////////////////////////////////////////////////////////////////// +/// Linkset scanning functions +/// These functions return linkset numbers + +// Returns a list of linkset numbers from a prim name +list LinksByName(string needle) { + list needles; + integer link = 1; + integer prims = llGetNumberOfPrims(); + for(; link <= prims; ++link) if(llGetLinkName(link) == needle) needles += link; + return needles; +} + +// Returns a linkset number based on the name +integer LinkByName(string needle) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) if(llGetLinkName(prims) == needle) return prims; + return FALSE; +} + +// Converts a list of prim names to a list of linkset numbers +list LinksetList(list needles) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) { + integer pointer = llListFindList(needles, [llGetLinkName(prims)]); + if(~pointer) needles = llListReplaceList(needles, [prims], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate +#define LinksetScan(conditions) \ + integer link = llGetNumberOfPrims();\ + do {\ + string linkName = llGetLinkName(link);\ + conditions\ + } while(--link > 1); + +/* Usage: +LinksetScan( + if(linkName == "Foot") Foot = link; + else if(linkName == "Leg") Leg = link; + else if(linkName == "Torso") Torso = link; + else if(linkName == "Head") Head = link; +); +*/ + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// Remote Object Linkset scanning variants +/// These functions return keys instead of linkset numbers + +// Returns a list of linkset keys from a prim name +list ObjectLinksByName(key object, string needle) { + list needles; + integer link = 1; + integer prims = llGetObjectPrimCount(object); + for(; link <= prims; ++link) + { + key linkKey = llGetObjectLinkKey(object, link); + string linkName = llKey2Name(linkKey); + if(linkName == needle) needles += linkKey; + } + return needles; +} + +// Returns a linkset key based on the name +key ObjectLinkByName(key object, string needle) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) + { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + if(linkName == needle) return linkKey; + } + return NULL_KEY; +} + +// Converts a list of prim names to a list of linkset keys +list ObjectLinksetList(key object, list needles) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + integer pointer = llListFindList(needles, [linkName]); + if(~pointer) needles = llListReplaceList(needles, [linkKey], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate +#define ObjectLinksetScan(object, conditions) \ + integer link = llGetObjectPrimCount(object);\ + do {\ + key linkKey = llGetObjectLinkKey(object, link);\ + string linkName = llKey2Name(linkKey);\ + conditions\ + } while(--link > 1); + +/* Usage: +ObjectLinksetScan(object, + if(linkName == "Turret") ... + else if(linkName == "Barrel") ... +); +*/ + + +// Scans an object for a list of sitting avatars +list ObjectLinksetSittingAvatars(key object) { + list details = llGetObjectDetails(object, [OBJECT_PRIM_COUNT, OBJECT_SIT_COUNT]); + integer prims = llList2Integer(details, 0); + integer sitters = llList2Integer(details, 1); + + list agents; + integer index = prims + 1; + integer count = prims + sitters; + for(; index <= count; ++index) agents += llGetObjectLinkKey(object, index); + + return agents; +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// "Linkset Resources" use Linkset Data to store/cache an array of linkset numbers in a compact format +/// The usecase is typically for reusable prims/meshes in a linkset such as in a HUD or user interface +/// For example for dynamically rendering markers, text or for data visualisation + +// Sets up a Linkset Resource on the key kv, finding prims in the linkset whose name exactly matches pattern to put into the resource +LinksetResourceSetup(string kv, string pattern) { + string links; + integer iterator = llGetNumberOfPrims(); + while(iterator --> 0) + { + string name = llGetLinkName(1 + iterator); + if(name == pattern) links = llChar(iterator) + links; + } + llLinksetDataWrite(kv, links); +} + +// Pulls out a linkset number from the resource +integer LinksetResourceReserve(string kv) { + string links = llLinksetDataRead(kv); + if(links == "") return FALSE; + llLinksetDataWrite(kv, llDeleteSubString(links, 0, 0)); + return 1 + llOrd(links, 0); +} + +// Releases a linkset number back into the resource +LinksetResourceRelease(string kv, integer link) { + llLinksetDataWrite(kv, llChar(link - 1) + llLinksetDataRead(kv)); +} + +// Resets the prim params for a Linkset Resource, e.g. to reset a bunch of reusable prims to a default state out of sight +LinksetResourceReset(string kv, list reset) { + list params; + string links = llLinksetDataRead(kv); + integer iterator = llStringLength(links); + while(iterator --> 0) + { + params += [PRIM_LINK_TARGET, 1 + llOrd(links, iterator)] + reset; + if(llGetFreeMemory() < 1500) { llSetLinkPrimitiveParamsFast(0, params); params = []; } + } + if(params) llSetLinkPrimitiveParamsFast(0, params); +} + +integer LinksetResourceLookup(string kv, integer index) { + string links = llLinksetDataRead(kv); + if(links) return 1 + llOrd(links, index); + else return FALSE; +} + + + +// These two functions build upon linkset resources at a higher level, so that you can trade linkset numbers between resources +// For example to have one linkset resource of unused reusable prims, and another for reusable prims you are currently using to render in a UI + +// Takes out a linkset number from the "Pool" resource and puts it into the "Used" resource +// You can flip the kv's to do the reverse +integer LinksetResourceUse(string kvPool, string kvUsed) { + string links = llLinksetDataRead(kvPool); + if(links == "") return FALSE; + llLinksetDataWrite(kvPool, llDeleteSubString(links, 0, 0)); + integer link = 1 + llOrd(links, 0); + llLinksetDataWrite(kvUsed, llChar(link - 1) + llLinksetDataRead(kvUsed)); + return link; +} + +// Frees up all the linkset numbers by moving all from the "Used" resource and puts them back into "Pool" resource +LinksetResourceFreeAll(string kvPool, string kvUsed) { + llLinksetDataWrite(kvPool, llLinksetDataRead(kvUsed) + llLinksetDataRead(kvPool)); + llLinksetDataWrite(kvUsed, ""); +} + +// TODO: A way to move a specific linkset number from a "Used" resource into the "Pool" resource in one function +// LinksetResourceFree(string kvPool, string kvUsed, integer link) {...} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/remote-linkset-data.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/remote-linkset-data.lsl new file mode 100644 index 0000000000..0a1bce70cc --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/remote-linkset-data.lsl @@ -0,0 +1,104 @@ +// Implementation of a public Remote Linkset Data storage system, similar in practice to llLinksetDataRead but on a remote object +// Gotcha is that this works entirely through prim params (names, descriptions and/or hover text to store data) +// This does mean that this it is mostly suitable for a server object to hold the remote linkset data + +// PRIM_NAME -- 63 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_DESC -- 127 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_TEXT -- 254 bytes, full UTF-8 range -- best for storing the raw values chunked across multiple prims +// -- Warning: Any PRIM_TEXT with unicode in range 0x1-0x1F via llChar corrupts the object from being rezzed from inventory +// Storage capacity is dependent on the number of prims in the linkset +// Values are divided by 254 bytes per child prim's PRIM_TEXT, but it is not guaranteed to utilise capacity fully +// as data is reserved in 254 byte chunks for each `name:value` pair, so e.g. if you have a linkset of 61 prims for example (where 1 prim is the root): +// - 60x`name:value` pairs if value is <= 254 bytes, or +// - 30x`name:value` pairs if values are up to 254*2 bytes. + +string RemoteLinksetDataRead(key object, string name) +{ + if(object == llGetKey() || object == NULL_KEY) return llLinksetDataRead("Remote." + name); + + // TODO: Lookup table on root prim? + integer link = 1; + @remoteReadSearch; + key prim = llGetObjectLinkKey(object, ++link); + if(prim == NULL_KEY) return ""; + string primName = llKey2Name(prim); + if(primName != name) jump remoteReadSearch; + + string value; + @remoteReadLoop; + value += (string)llGetObjectDetails(prim, [OBJECT_TEXT]); + prim = llGetObjectLinkKey(object, ++link); + primName = llKey2Name(prim); + if(primName == name) jump remoteReadLoop; + + return value; +} + +integer RemoteLinksetDataWrite(key object, string name, string value) +{ + if(object != llGetKey() && object != NULL_KEY) return 6; // Currently only allow writing from own object to keep the implementation straightforward + + // TODO: Check many prims we have left available for writing (any prims free at all + chunk/prim for full value) + + // We mirror the data locally for rebuilds + integer result = llLinksetDataWrite("Remote." + name, value); + if(result) return result; + + RebuildRemoteLinksetData(); + + return LINKSETDATA_OK; +} + + + +// INTERNAL -- Rebuild entire remote dataset, this is not efficient but it keeps the implementation robust, avoiding defragmentation issues +RebuildRemoteLinksetData() +{ + list params; + + list names = llLinksetDataFindKeys("^Remote\\.", 0, 0); + integer link = 2; + #ifdef RLSD_LINK_START + link = RLSD_LINK_START; + #endif + integer index; + integer count = llGetListLength(names); + vector lastColor; + for(; index < count; ++index) + { + string name = llList2String(names, index); + string value = llLinksetDataRead(name); + name = llDeleteSubString(name, 0, 6); + + vector color = (vector)llLinksetDataRead("RemoteDebug." + name); + while(color == ZERO_VECTOR || color * lastColor > 0.8) llLinksetDataWrite("RemoteDebug." + name, string(color = llVecNorm())); + lastColor = color; + + integer chunks = llCeil(llStringLength(value) / 254.); + while(chunks --> 0) + { + string chunk = llGetSubString(value, 0, 253); + value = llDeleteSubString(value, 0, 253); + params += [ + PRIM_LINK_TARGET, link++, + PRIM_NAME, name, + PRIM_TEXT, chunk, <0, 0, 0>, 0 + + #ifdef RLSD_DISPLAY + , PRIM_COLOR, 0, color, 1 + #endif + ]; + } + } + + // TODO: Lookup table on root prim? + + llSetLinkPrimitiveParamsFast(LINK_ALL_CHILDREN, [ + PRIM_NAME, "-", + PRIM_TEXT, "", <0, 0, 0>, 0 + + #ifdef RLSD_DISPLAY + , PRIM_COLOR, 0, <0, 0, 0>, 1 + #endif + ] + params); +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/rez-queue-check.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/rez-queue-check.lsl new file mode 100644 index 0000000000..591343baba --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/rez-queue-check.lsl @@ -0,0 +1,251 @@ +#include "NexiiLSL/app-uri.lsl" + +float start; +float end; +integer hasQueue; +integer hasLargeQueue; +list tracked; + +integer lastCount; +integer tick; + +checkAttachments() +{ + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + string animation = llGetAnimation(agent); + string legacyName = llKey2Name(agent); // Ghosted avatars have an empty string + string displayName = llGetDisplayName(agent); // May not always be non-empty string? + string userName = llGetUsername(agent); // May not always be non-empty string? + list attachments = llGetAttachedList(agent); + + if(animation == "" + || animation == "Init" + || legacyName == "" + || displayName == "" + || userName == "" + || llGetListLength(attachments) == 0) + jump continue; // Do not consider agent if entering/exiting region, logging, teleporting or ghosted + + list prevAttachments = llJson2List(llLinksetDataRead(agent + "_attachments")); + list attached; + list detached; + + // Attached + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + integer pointer = llListFindList(prevAttachments, [attachment]); + if(pointer == -1) + { + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + attached += attachment; + } + } + + // Detached + index = llGetListLength(prevAttachments); + while(index --> 0) + { + string attachment = llList2String(prevAttachments, index); + integer pointer = llListFindList(attachments, [(key)attachment]); + if(pointer == -1) + { + // llLinksetDataDelete(agent + "_" + attachment); + detached += attachment; + } + } + + // Cache + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + + + //if(hasQueue || hasLargeQueue) + //{ + //string message; + integer a = llGetListLength(attached); + integer d = llGetListLength(detached); + /*if(a && d) message = agentAboutURI(agent) + " attached " + string(a) + " and detached " + string(d) + " items"; + else if(a) message = agentAboutURI(agent) + " attached " + string(a) + " items"; + else if(d) message = agentAboutURI(agent) + " detached " + string(d) + " items";*/ + + if(a) tracked += agentAboutURI(agent); + + /*while(a --> 0) + { + string attachment = llList2String(attached, a); + message += "\n+ " + objectURI(attachment); + }*/ + + while(d --> 0) + { + string attachment = llList2String(detached, d); + // message += "\n- " + llLinksetDataRead(attachment + "_name"); + llLinksetDataDelete(attachment + "_name"); + // llLinksetDataDelete(agent + "_" + attachment); + } + + //if(message) llOwnerSay(message); + //} + + @continue; + } +} + +announce(string message) { + string name = llGetObjectName(); + llSetObjectName(llGetRegionName()); + + //llSay(PUBLIC_CHANNEL, message); + + //* + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + key agent = llList2Key(agents, iterator); + llRegionSayTo(agent, PUBLIC_CHANNEL, message); + } + //*/ + + llSetObjectName(name); +} + + +default +{ + state_entry() + { + llSetText("", <1,1,1>, 1); + llLinksetDataDeleteFound("_(attachments|name)$", ""); + + list agents = llGetAgentList(AGENT_LIST_REGION, []); + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + integer iterator = lastCount = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + } + } + + llOwnerSay("Initialise Rez Queue Checker (Mem " + llGetSubString((string)(llGetFreeMemory() / 1024.0), 0, -6) + "KB / LSD " + llGetSubString((string)(llLinksetDataAvailable() / 1024.0), 0, -6) + "KB)"); + // llOwnerSay((string)); + + llSetTimerEvent(20/45.); + checkAttachments(); + } + + timer() + { + if(llGetTime() > 60*60*12) llResetScript(); + + ++tick; + + integer count = llGetRegionAgentCount(); + if(lastCount != count) + { + lastCount = count; + list lastAgents = llJson2List(llLinksetDataRead("agents")); + list agents = llGetAgentList(AGENT_LIST_REGION, []); + list added; + list removed; + while(count --> 0) + { + string agent = llList2String(agents, count); + integer pointer = llListFindList(lastAgents, [agent]); + if(pointer == -1) + { + added += agentAboutURI(agent); + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + } + } + + count = llGetListLength(lastAgents); + while(count --> 0) + { + string agent = llList2String(lastAgents, count); + integer pointer = llListFindList(agents, [(key)agent]); + if(pointer == -1) + { + removed += agentAboutURI(agent); + llLinksetDataDeleteFound("^" + agent, ""); + } + } + + //if(removed) llOwnerSay("Removed: " + llList2CSV(removed)); + //if(added) llOwnerSay("Added: " + llList2CSV(added)); + + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + } + + float now = llGetTime(); + float downloads = llGetSimStats(SIM_STAT_ASSET_DOWNLOADS); + float uploads = llGetSimStats(SIM_STAT_ASSET_UPLOADS); + + // if(downloads > 0.5 || uploads > 0.5) + if(downloads > 0.01 || uploads > 0.01) + { + if(!hasQueue) + { + //llOwnerSay("Downloads pending!"); + hasQueue = TRUE; + end = start = now; + } + + else if(hasQueue) + { + end = now; + if(downloads > 0.9 && /*now - end > 1.5 &&*/ !hasLargeQueue) + { + hasLargeQueue = TRUE; + tracked = []; + announce("Rez queue detected!"); + } + } + } + + else if(hasLargeQueue) + { + float delta = end - start; + start = end = 0; + checkAttachments(); + string message = "Rez queue ended after " + llGetSubString((string)delta, 0, -5) + "s"; + if(tracked) message += " due to " + llDumpList2String(tracked, ", "); + announce(message); + hasQueue = hasLargeQueue = FALSE; + tracked = []; + } + + else if(hasQueue) + { + float delta = end - start; + checkAttachments(); + //llOwnerSay("Pending over after " + llGetSubString((string)delta, 0, -5) + "s"); + hasQueue = FALSE; + } + + else if(!(tick % 45)) checkAttachments(); + } + + changed(integer change) + { + if(change & CHANGED_REGION) llResetScript(); + } + + attach(key avatar) + { + llResetScript(); + } +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/roundabout.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/roundabout.lsl new file mode 100644 index 0000000000..d00f54cf11 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/roundabout.lsl @@ -0,0 +1,244 @@ +/* +The Magic Roundabout + +This script is designed to help with the dilemma of assigning one script in the region +as the main controller responsible for loading remote data such as from experience storage +or HTTP requests and then having to distribute any loaded data to other subordinate scripts + +Make sure the script is set to no modify so people can't peek at the private key used +-- that is the only piece of info that must truly be kept secret + +Because the script sends along a signature in the listener messages, it means you are +limited to 852 bytes max (1024 - 172) +*/ + +#include "NexiiLSL/time.lsl" + +#define CHANNEL_ROUNDABOUT -100 // replace with secret channel + +string PrivateKey = "-----BEGIN RSA PRIVATE KEY----- +replace with private key +-----END RSA PRIVATE KEY-----"; +string PublicKey = "-----BEGIN PUBLIC KEY----- +replace with public key +-----END PUBLIC KEY-----"; +float LastPingPong = -60.0; +float LastController = -60.0; + +key Controller = NULL_KEY; +list Subordinates; + +integer Tick; + +default +{ + state_entry() + { + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "init", ""); + llLinksetDataWrite("Roundabout", "init"); + state subordinate; + } +} + +state subordinate +{ + state_entry() + { + llListen(CHANNEL_ROUNDABOUT, "", "", ""); + llSetTimerEvent(10.0); + + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + payload); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + LastPingPong = llGetTime(); + + // Respond to ping with pong + text = "pong," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + } + + else if(message == "pong") + { + LastPingPong = llGetTime(); + + // Was controller, but might have been reset + if(identifier == Controller) Controller = NULL_KEY; + + // Add to subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer == -1) Subordinates += identifier; + } + + else if(message == "controller") + { + LastController = llGetTime(); + + // We just initialised and there is a controller out there + // Announce to other scripts we are subordinate and ready to request data + if(Controller == NULL_KEY) + { + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "subordinate", ""); + Controller = identifier; + } + + // New controller + else if(identifier != Controller) Controller = identifier; + + // Same controller + else if(identifier == Controller); + + // Remove from subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer != -1) Subordinates = llDeleteSubList(Subordinates, pointer, pointer); + } + + // Pass along any other messages from controller into linkset + else if(identifier == Controller) + { + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, text, identifier); + } + } + + timer() + { + ++Tick; + float time = llGetTime(); + + // We lost our controller + if(time - LastController > 60.0 || (Controller != NULL_KEY && llKey2Name(Controller) == "")) + { + // Promote oldest subordinate to controller + key subordinate = llGetKey(); + list checks = [subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME]))]; + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) != "") checks += [ + subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME])) + ]; + } + checks = llListSortStrided(checks, 2, 1, TRUE); + subordinate = llList2Key(checks, 0); + + // Promote ourselves + if(subordinate == llGetKey()) state controller; + + // Promote subodinate to controller + else Controller = subordinate; + } + + // Ping everyone + else if(time - LastPingPong > 30.0) + { + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + payload); + } + + // Garbage collection on list + if(!(Tick % 16)) + { + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) == "") + { + llOwnerSay("Lost subordinate " + (string)subordinate); + Subordinates = llDeleteSubList(Subordinates, iterator, iterator); + } + } + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == CHANNEL_ROUNDABOUT + 1 && Controller != NULL_KEY) + { + // Pass along messages from subordinate to controller + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSayTo(Controller, CHANNEL_ROUNDABOUT, signature + text); + } + } + + state_exit() + { + // We don't track subordinates outside this state + Subordinates = []; + } +} + + +state controller +{ + state_entry() + { + string text = "controller," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + + llLinksetDataWrite("Roundabout", "controller"); + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "controller", ""); + llListen(CHANNEL_ROUNDABOUT, "", "", ""); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + // Respond to ping with controller + text = "controller," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + } + else if(message == "pong"); // Ignore + else if(message == "controller") + { + // Huh? There was another controller? + LastController = llGetTime(); + Controller = identifier; + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, "subordinate", ""); + state subordinate; + } + + // Pass along any other messages from subordinates into linkset + else + { + llMessageLinked(LINK_SET, CHANNEL_ROUNDABOUT, text, identifier); + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == CHANNEL_ROUNDABOUT + 1) + { + // Pass along messages from controller to subordinates + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(CHANNEL_ROUNDABOUT, signature + text); + } + } +} \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/sync-lsd.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/sync-lsd.lsl new file mode 100644 index 0000000000..0e0f5221c8 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/scripts/sync-lsd.lsl @@ -0,0 +1,632 @@ +/* + Synchronised Linkset Data + + This script synchronises specific linkset data keys across the region that match a prefix, per the config. + + On initialisation, rezzing or changing region it does a full reset/synchronisation with the full + tracked dataset (per scopes) of the network and then simply listens for changes as they propagate across the region. + + Each object can be configured to only sync specific prefixes to optimise bandwidth and processing. + + If you want to persist data across reboots (e.g. if syncing data across temp attach / intermittent objects), + make sure to establish a permanent server object that holds data. +*/ + +// SETTINGS BELOW CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +/// Security +// We use RSA signatures to verify secure communications +string privateKey = "-----REPLACE WITH RSA PRIVATE KEY-----"; +string publicKey = "-----REPLACE WITH RSA PUBLIC KEY-----"; +// We use a protected linkset data key to prevent spoofing due to extracted scripts +// You only need this when using this script in no-mod attachments, e.g. experience temp attach +string protectedName = "SYNC_LSD_PROTECTED"; +string protectedKey = "-----REPLACE WITH SECRET-----"; + +/// Scope prefixes of linkset data keys to synchronise +list scopes = [ + "Test_", + "Foo_", + "Bar_" +]; +// Corresponding listener channels for each scope above +list scopeChannels = [ + 204505, + 204506, + 204507 +]; + +// SETTINGS ABOVE CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// Link Message Numbers +#define MESSAGE_SYNC_BOOT 1 +#define MESSAGE_SYNC_READY 2 + +// Event Types Enumeration +#define EVENT_TYPE 63 +#define EVENT_PING 1 +#define EVENT_PONG 2 +#define EVENT_SYNC_REQUEST 3 +#define EVENT_SYNC_RESPONSE 4 +#define EVENT_SYNC_COMPLETE 5 +#define EVENT_DATA 6 +#define EVENT_MESSAGE 7 +// Event Bit flags +#define EVENT_FRAGMENT 64 +#define EVENT_FINAL_FRAGMENT 128 + + +list candidates = [/* key object, string scope */]; +list pending = [/* integer action, string name */]; +list verified = [/* key object */]; +list buffer = [/* string name, string value */]; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +integer inScope(string name) { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) return TRUE; + } + return FALSE; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + +sendData(key target, integer channel, integer eventType, string name, string value) { + integer nameLength = llStringLength(name); + integer available = 1023 - (3 + nameLength); + integer total = getStringBytes(value); + integer isASCII = (total == llStringLength(value)); // Prefer llStringLength as its fastest + + // Fragment the update across multiple messages + if(total > available) + { + eventType = eventType | EVENT_FRAGMENT; + while(value) + { + string fragment = ""; + integer width = 0; + while(total > 0) + { + string part = llGetSubString(value, 0, 250); + integer bytes; + if(isASCII) bytes = llStringLength(part); + else bytes = getStringBytes(part); + + if(width + bytes <= available) + { + value = llDeleteSubString(value, 0, 250); + width += bytes; + total -= bytes; + fragment += part; + } + else jump breakFragment; + } + @breakFragment; + + if(value == "") eventType = eventType | EVENT_FINAL_FRAGMENT; + + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + fragment; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } + } + + // Direct message + else + { + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + value; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Initialise by pinging all and get latest dataset from network +default +{ + state_entry() + { + if(llLinksetDataReadProtected(protectedName, protectedKey) == "") + { + llDetachFromAvatar(); + llDie(); + return; + } + + llMessageLinked(LINK_SET, MESSAGE_SYNC_BOOT, "", ""); + + // Reset scoped data, setup listeners and ping everyone + integer iterator = llGetListLength(scopes); + string signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + integer signatureLength = llStringLength(signature); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + llLinksetDataDeleteFound("^" + prefix, ""); + integer channel = llList2Integer(scopeChannels, iterator); + llListen(channel, "", "", ""); + llRegionSay(channel, llChar(EVENT_PING) + llChar(signatureLength) + signature + prefix); + } + + // Timeout from pongs + llSetTimerEvent(0.5); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & EVENT_TYPE; + integer eventFlags = header & ~EVENT_TYPE; + + if(eventType == EVENT_PONG) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Don't add to verified list yet, check scope first + + // Refresh timeout + llSetTimerEvent(FALSE); + llSetTimerEvent(0.5); + + // Add to candidates if scope matches + integer isVerified = (llListFindList(verified, [identifier]) != -1); + list peerScope = llParseString2List(llGetSubString(message, 2 + signatureLength, -1), [llChar(1)], []); + integer peerIterator = llGetListLength(peerScope); + while(peerIterator --> 0) + { + string peerPrefix = llList2String(peerScope, peerIterator); + + // Check if each peer prefix matches any of our scopes + integer ownIterator = llGetListLength(scopes); + while(ownIterator --> 0) + { + string ownPrefix = llList2String(scopes, ownIterator); + + // Phase 1: Check if peer prefix is wider, shorter or exact match + // Is peer prefix wider? + if(llSubStringIndex(peerPrefix, ownPrefix) != 0) + { + // Is peer prefix shorter? + if(llSubStringIndex(ownPrefix, peerPrefix) != 0) + { + // Peer prefix doesnt match our scope to track, so skip it + jump nextPeerPrefix; + } + } + + if(!isVerified) + { + // Mark as verified + integer iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + verified += identifier; + isVerified = TRUE; + } + + // Phase 2: Compare with candidates; Check if already exact match or is shorter, otherwise delete any wider prefixes + integer candidatesIterator = llGetListLength(candidates); + while(candidatesIterator > 0) + { + candidatesIterator -= 2; + string candidatePrefix = llList2String(candidates, candidatesIterator + 1); + + if(peerPrefix == candidatePrefix) + jump nextPeerPrefix; // Already have this candidate; + + // Is candidate shorter than peer? + else if(llSubStringIndex(peerPrefix, candidatePrefix) == 0) + jump nextPeerPrefix; // Skip adding peer as candidate as we already have a better match + + // Is candidate wider than peer? + else if(llSubStringIndex(candidatePrefix, peerPrefix) == 0) + // Delete candidate as peer is a better match + candidates = llDeleteSubList(candidates, candidatesIterator, candidatesIterator + 1); + } + + // Phase 3: Add peer as candidate + candidates += [identifier, peerPrefix]; + + @nextPeerPrefix; + } + @nextPeer; + } + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Sync event + if(eventType == EVENT_SYNC_RESPONSE) + { + // integer action = llOrd(message, 1) - 1; + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + string value = llGetSubString(message, 3 + nameLength, -1); + + if(!inScope(name)) return; + + if(eventFlags & EVENT_FRAGMENT) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & EVENT_FINAL_FRAGMENT) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + llLinksetDataWrite(name, value); + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else llLinksetDataWrite(name, value); + } + + else if(eventType == EVENT_SYNC_COMPLETE) + { + // If no more candidates, move to sync state + if(candidates); else state sync; + + // Pull out the next candidate to sync + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(EVENT_SYNC_REQUEST) + llDumpList2String(peerScopes, llChar(1)) + ); + } + } + + timer() + { + llSetTimerEvent(FALSE); + + // If no candidates, move directly to sync state + if(candidates); else state sync; + + // Sort candidates by key + candidates = llListSort(candidates, 2, TRUE); + + // Pull out a candidate + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(EVENT_SYNC_REQUEST) + llDumpList2String(peerScopes, llChar(1)) + ); + } + + state_exit() + { + llMessageLinked(LINK_SET, MESSAGE_SYNC_READY, "", ""); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Idle away, synchronising data as needed +state sync +{ + on_rez(integer param) { llResetScript(); } + changed(integer change) { if(change & CHANGED_REGION) llResetScript(); } + state_entry() + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) llListen(llList2Integer(scopeChannels, iterator), "", "", ""); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & EVENT_TYPE; + integer eventFlags = header & ~EVENT_TYPE; + + if(eventType == EVENT_PING) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Check if peer prefix matches any of our scopes + integer hasMatch = FALSE; + list matchedScopes; + string peerPrefix = llGetSubString(message, 2 + signatureLength, -1); + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string ownPrefix = llList2String(scopes, iterator); + if(llSubStringIndex(peerPrefix, ownPrefix) == 0) matchedScopes += ownPrefix; + else if(llSubStringIndex(ownPrefix, peerPrefix) == 0) matchedScopes += ownPrefix; + } + if(matchedScopes); else return; + + // Mark as verified + iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + if(llListFindList(verified, [identifier]) == -1) verified += identifier; + + // Respond with pong + signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + signatureLength = llStringLength(signature); + llRegionSayTo(identifier, channel, + llChar(EVENT_PONG) + + llChar(signatureLength) + + signature + + llDumpList2String(matchedScopes, llChar(1)) + ); + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Remote linkset data event + if(eventType == EVENT_DATA) + { + integer action = llOrd(message, 1) - 1; + + if(action == LINKSETDATA_UPDATE) + { + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + + if(!inScope(name)) return; + + string value = llGetSubString(message, 3 + nameLength, -1); + + if(eventFlags & EVENT_FRAGMENT) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & EVENT_FINAL_FRAGMENT) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else + { + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + } + + else if(action == LINKSETDATA_DELETE) + { + string name = llGetSubString(message, 2, -1); + if(!inScope(name)) return; + if(llLinksetDataDelete(name) == LINKSETDATA_OK) pending += [action, name]; + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + integer prefixLength = llOrd(message, 2); + string prefix = llGetSubString(message, 3, 2 + prefixLength); + // if(!inScope(prefix)) return; -- This is complex as the source may be wider or narrower than our scopes but still have relevant data, hmm... + // For now we cheat a bit because delete found can tell us how many were deleted + // There is however danger for overlap between local and synced data if they have the same names but weren't intended to be synced + + // Escape regex special characters and prepare bucket as a regex pattern + string bucket = llGetSubString(message, 3 + prefixLength, -1); + bucket = llReplaceSubString(bucket, ".", "\\.", 0); + bucket = llReplaceSubString(bucket, "^", "\\^", 0); + bucket = llReplaceSubString(bucket, "$", "\\$", 0); + bucket = llReplaceSubString(bucket, "*", "\\*", 0); + bucket = llReplaceSubString(bucket, "+", "\\+", 0); + bucket = llReplaceSubString(bucket, "?", "\\?", 0); + bucket = llReplaceSubString(bucket, "(", "\\(", 0); + bucket = llReplaceSubString(bucket, ")", "\\)", 0); + bucket = llReplaceSubString(bucket, "{", "\\{", 0); + bucket = llReplaceSubString(bucket, "}", "\\}", 0); + bucket = llReplaceSubString(bucket, "|", "\\|", 0); + bucket = "^" + prefix + "(" + llReplaceSubString(bucket, llChar(1), "|", 0) + ")$"; + integer deleted = llList2Integer(llLinksetDataDeleteFound(bucket, ""), 0); + if(deleted) pending += [action, ""]; + } + + else if(action == LINKSETDATA_RESET) + { + string prefix = llGetSubString(message, 2, -1); + if(!inScope(prefix)) return; + // llLinksetDataReset(); -- Takes out all data even locally scoped, so we convert it to a multi-delete instead + integer deleted = llList2Integer(llLinksetDataDeleteFound("^" + prefix, ""), 0); + if(deleted) pending += [LINKSETDATA_MULTIDELETE, ""]; + } + } + + // TODO: Would this be desirable? Would have to specify a common channel though. The idea would be to piggyback on the secure comms to send messages across + // // Remote link message event + // else if(eventType == EVENT_MESSAGE) + // { + // integer textLength = llOrd(message, 1); + // string text = llGetSubString(message, 2, 1 + textLength); + // string text2 = llGetSubString(message, 2 + textLength, -1); + // llMessageLinked(LINK_SET, MESSAGE_SYNC, text, text2); + // } + + // Full synchronisation with peer + else if(eventType == EVENT_SYNC_REQUEST) + { + list requestScopes; + integer requestIterator; + integer requestTotal; + if(llStringLength(message) > 1) + { + requestScopes = llParseString2List(llGetSubString(message, 1, -1), [llChar(1)], []); + requestTotal = llGetListLength(requestScopes); + } + + integer sent = 0; + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string pattern = "^" + llList2String(scopes, iterator); + list names = llLinksetDataFindKeys(pattern, 0, FALSE); // TODO: Change to paginate the results + integer nameIterator = llGetListLength(names); + while(nameIterator --> 0) + { + string name = llList2String(names, nameIterator); + if(requestTotal) + { + for(requestIterator = 0; requestIterator < requestTotal; ++requestIterator) + { + string requestPrefix = llList2String(requestScopes, requestIterator); + if(llSubStringIndex(name, requestPrefix) == 0) jump requestMatch; + } + jump requestSkip; + } + @requestMatch; + + string value = llLinksetDataRead(name); + sendData(identifier, channel, EVENT_SYNC_RESPONSE, name, value); + if((++sent % 32) == 0) llSleep(4/45.); // Avoid flooding + + @requestSkip; + } + } + + llRegionSayTo(identifier, channel, llChar(EVENT_SYNC_COMPLETE)); + } + } + + linkset_data(integer action, string name, string value) + { + // Skip if this was expected and a change by this script from an external source + if(pending) + { + integer pendingAction = llList2Integer(pending, 0); + string pendingName = llList2String(pending, 1); + if(action == pendingAction) + { + if((action == LINKSETDATA_UPDATE || action == LINKSETDATA_DELETE) && name == pendingName) + { pending = llDeleteSubList(pending, 0, 1); return; } + + else // MULTIDELETE or RESET + { pending = llDeleteSubList(pending, 0, 1); return; } + } + } + + if(action == LINKSETDATA_UPDATE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + return sendData(NULL_KEY, channel, EVENT_DATA, name, value); + } + } + } + + else if(action == LINKSETDATA_DELETE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(EVENT_DATA) + llChar(action + 1) + name); + return; + } + } + } + + else if(action == LINKSETDATA_RESET) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(EVENT_DATA) + llChar(action + 1) + prefix); + } + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + list deleted = llParseString2List(name, [","], []); + list known; + integer total = llGetListLength(scopes); + integer iterator = total; + while(iterator --> 0) known += ""; + + do { + name = llList2String(deleted, 0); + deleted = llDeleteSubList(deleted, 0, 0); + + iterator = total; + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + name = llDeleteSubString(name, 0, llStringLength(prefix) - 1); + + string bucket = llList2String(known, iterator); + if(bucket) bucket += llChar(1) + name; else bucket += name; + known = llListReplaceList(known, [bucket], iterator, iterator); + } + } + } while(deleted); + + iterator = total; + while(iterator --> 0) + { + string bucket = llList2String(known, iterator); + if(bucket) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(EVENT_DATA) + llChar(action + 1) + llChar(llStringLength(prefix)) + prefix + bucket); + // TODO: Check if we need to fragment the bucket across multiple messages + } + } + } + } +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/string-compression.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/string-compression.lsl new file mode 100644 index 0000000000..d3eba96a27 --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/string-compression.lsl @@ -0,0 +1,142 @@ +#include "NexiiLSL/utilities.lsl" + +// Available bytes for UTF8 packing: +#define UTF8_1B_MAX 0x7F +#define UTF8_2B_MAX 0x07FF +#define UTF8_3B_MAX 0xFFFF +#define UTF8_4B_MAX 0x10FFFF +#define UTF8_SURROGATE_START 0xD800 +#define UTF8_SURROGATE_END 0xDFFF +#define UTF8_NONCHARACTER_1 0xFFFE +#define UTF8_NONCHARACTER_2 0xFFFF + + +default +{ + state_entry() + { + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned integer (0 to UTF8_4B_MAX-1) + // Can save a byte or two but importantly provides array-like access + { + integer input = 0x10FFFF - 1; + + string compressed = llChar(1 + input); + + integer output = llOrd(compressed, 0) - 1; + + llOwnerSay( + "Compact int: " + (string)input + " => " + compressed + " => " + (string)output + "\n" + + "Compression " + (string)stringBytes((string)input) + "b => " + (string)stringBytes(compressed) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned float + { + #define MAXIMUM 2048.0 + float quantizer = (UTF8_1B_MAX - 1) / RANGE; + + float input = 1337.256; + + string compressed = llChar(1 + integer(input * quantizer); + + float output = (llOrd(compressed, 0) - 1) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact signed float + { + #define RANGE 2048.0 + float quantizer = (UTF8_1B_MAX - 1) / RANGE / 2; + + float input = 1337.256; + + string compressed = llChar(1 + integer(quantizer + input * quantizer); + + float output = ((llOrd(compressed, 0) - 1) - quantizer) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact vector (region coords <0,0,0> to <256,256,4096>) + { + // Quantization from 0-4096 / 0-256 + float xyquant = (UTF8_1B_MAX - 1) / 4096.; + float zquant = (UTF8_1B_MAX - 1) / 256.; + vector a = <250.525, 128.128128, 3059.999>; + + // Compress + string b = llChar(1 + integer(a.x * xyquant)) + + llChar(1 + integer(a.y * xyquant)) + + llChar(1 + integer(a.z * zquant)); + + // Decompress + vector c = < + (llOrd(b, 0) - 1) / xyquant, + (llOrd(b, 1) - 1) / xyquant, + (llOrd(b, 2) - 1) / zquant + >; + } + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact keys (36 -> 8 chars); Keys are really 16 byte values, so we can pack them tightly + // There is probably a better way to do this to fit into the 4 byte UTF-8 chars and deserialise it inline + { + key a = "64064c89-25aa-008b-cfe7-0c34e28ae523"; + + // Compress + string b = llChar(integer("0x" + llGetSubString(a, 0, 3))) + + llChar(integer("0x" + llGetSubString(a, 4, 7))) + + llChar(integer("0x" + llGetSubString(a, 9, 12))) + + llChar(integer("0x" + llGetSubString(a, 14, 17))) + + llChar(integer("0x" + llGetSubString(a, 19, 22))) + + llChar(integer("0x" + llGetSubString(a, 24, 27))) + + llChar(integer("0x" + llGetSubString(a, 28, 31))) + + llChar(integer("0x" + llGetSubString(a, 32, 35))); + + // Decompress + key c = bits2nybbles(llOrd(b, 0)) + bits2nybbles(llOrd(b, 1)) + + "-" + bits2nybbles(llOrd(b, 2)) + "-" + bits2nybbles(llOrd(b, 3)) + + "-" + bits2nybbles(llOrd(b, 4)) + "-" + bits2nybbles(llOrd(b, 5)) + + bits2nybbles(llOrd(b, 6)) + bits2nybbles(llOrd(b, 7)); + + llOwnerSay( + "Compact Key (36 -> 8): " + (string)a + " => '" + b + "' => " + (string)c + "\n" + + "Compression " + (string)stringBytes(a) + "b => " + (string)stringBytes(b) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Compact rotations (<-1,-1,-1,-1> to <1,1,1,1>) + { + float quantizer = UTF8_4B_MAX / 2; + vector a = <-2, 27, 120>; + rotation b = llEuler2Rot(a * DEG_TO_RAD); + + // Compress + string c = llChar(1 + integer(quantizer + b.x * quantizer)) + + llChar(1 + integer(quantizer + b.y * quantizer)) + + llChar(1 + integer(quantizer + b.z * quantizer)) + + llChar(1 + integer(quantizer + b.s * quantizer)); + + // Decompress + rotation d = < + ((llOrd(c, 0) - 1) - quantizer) / quantizer, + ((llOrd(c, 1) - 1) - quantizer) / quantizer, + ((llOrd(c, 2) - 1) - quantizer) / quantizer, + ((llOrd(c, 3) - 1) - quantizer) / quantizer + >; + + vector e = llRot2Euler(d) * RAD_TO_DEG; + + llOwnerSay( + "Compact rot: " + (string)a + " => " + (string)b + " => '" + c + "' => " + (string)d + " => " + (string)e + "\n" + + "Compression " + (string)stringBytes(llList2CSV([a,b])) + "b => " + (string)stringBytes(c) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + + + } +} \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/texture.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/texture.lsl new file mode 100644 index 0000000000..8f6d7dd26a --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/texture.lsl @@ -0,0 +1,82 @@ +// This is the heart of managing texture coordinates, by converting pixel coordinates into SL texture uv coords +#define TEXTURE_REPEAT(w, h, textureWidth, textureHeight) +#define TEXTURE_OFFSET(x, y, textureWidth, textureHeight) \ + <((x) - float(textureWidth)/2) / float(textureWidth),\ + -((y) - float(textureHeight)/2) / float(textureHeight), 0> +#define TEXTURE_COORDS(x, y, width, height, textureWidth, textureHeight) \ + ,\ + <((x) - float(textureWidth)/2.) / float(textureWidth),\ + -((y) - float(textureHeight)/2.) / float(textureHeight), 0> +#define TEXTURE_RECT(x, y, width, height, textureWidth, textureHeight) \ + ,\ + <((x) + width/2. - float(textureWidth)/2.) / float(textureWidth),\ + -((y) + height/2. - float(textureHeight)/2.) / float(textureHeight), 0> + +// For rendering icons placed neatly into an even grid layout in a square texture resolution +#define SPRITESHEET(icon, size, resolution) \ + TEXTURE_COORDS(\ + size*.5 + size * icon % (resolution/size),\ + size*.5 - size * icon / llFloor(resolution/size),\ + size, size,\ + resolution, resolution\ + ) + +// For non-uniform grid layouts as well as textures with non-square aspect ratio +#define SPRITESHEET2(icon, iconWidth, iconHeight, textureWidth, textureHeight) \ + TEXTURE_COORDS(\ + iconWidth*.5 + iconWidth * digit % llFloor(textureWidth/iconWidth),\ + iconHeight*.5 - iconHeight * digit / llFloor(textureWidth/iconWidth),\ + iconWidth, iconHeight,\ + textureWidth, textureHeight\ + ) + + +// Compact spritesheet functions by hardcoding resolution and icon sizes +#define TEXTURE_CURSORS "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd" +#define CURSOR(a, b) <256/2048., 256/512., 0>, <(128 + (256*a) - 1024)/2048., (256 - 128 + (256*b))/512., 0> +// equivalent to #define CURSOR(a, b) TEXTURE_REPEAT(256, 256, 2048., 512.), TEXTURE_OFFSET(, , 2048., 512.) + + + +// Old macros moved from utilities.lsl +// #define TEXTURE_SCALE(w, h) +// #define TEXTURE_OFFSET(w, h, x, y) <(w*.5 + x - RESOLUTION*.5) / RESOLUTION, (RESOLUTION*.5 - (h*.5 + y)) / RESOLUTION, 0> +// #define TEXTURE_SIZE(w, h) w / RESOLUTION*.5, h / RESOLUTION*.5 + + +/* +Texture maths, pixels to coords + +default +{ + state_entry() + { + #define RESOLUTION 1024. + #define FACE 4 + + float width = 181.5; + float height = 116.5; + float x = 30; + float y = 608; + + llScaleTexture( + width / RESOLUTION, + height / RESOLUTION, + FACE + ); + llOffsetTexture( + (width*.5 + x - RESOLUTION*.5) / RESOLUTION, + (RESOLUTION*.5 - (height*.5 + y)) / RESOLUTION, + FACE + ); + llSetScale(< + 0.04, + width/RESOLUTION*.5, + height/RESOLUTION*.5 + >); + } +} + +*/ \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/time.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/time.lsl new file mode 100644 index 0000000000..c94172526d --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/time.lsl @@ -0,0 +1,107 @@ +/* + Compares two timestamps per ISO 8601 format "YYYY-MM-DDThh:mm:ss.ff..fZ" + Returns -1 if a < b, else 1 if a > b else 0 if same + + You can comment/cut out parts of the function if you only care about + date checks or only want fast time checks +*/ +integer CompareTimestamps(string a, string b) +{ + integer aYear = (integer)llGetSubString(a, 0, 3); + integer bYear = (integer)llGetSubString(b, 0, 3); + if(aYear < bYear) return -1; else if(aYear > bYear) return 1; + integer aMonth = (integer)llGetSubString(a, 5, 6); + integer bMonth = (integer)llGetSubString(b, 5, 6); + if(aMonth < bMonth) return -1; else if(aMonth > bMonth) return 1; + integer aDay = (integer)llGetSubString(a, 8, 9); + integer bDay = (integer)llGetSubString(b, 8, 9); + if(aDay < bDay) return -1; else if(aDay > bDay) return 1; + integer aHour = (integer)llGetSubString(a, 11, 12); + integer bHour = (integer)llGetSubString(b, 11, 12); + if(aHour < bHour) return -1; else if(aHour > bHour) return 1; + integer aMinute = (integer)llGetSubString(a, 14, 15); + integer bMinute = (integer)llGetSubString(b, 14, 15); + if(aMinute < bMinute) return -1; else if(aMinute > bMinute) return 1; + float aSecond = (float)llGetSubString(a, 17, -2); + float bSecond = (float)llGetSubString(b, 17, -2); + if(aSecond < bSecond) return -1; else if(aSecond > bSecond) return 1; + return 0; +} + +// Pure string version of https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Unix(string stamp) +{ + integer year = (integer)llGetSubString(stamp, 0, 3); + integer month = (integer)llGetSubString(stamp, 5, 6); + integer day = (integer)llGetSubString(stamp, 8, 9); + integer hours = (integer)llGetSubString(stamp, 11, 12); + integer minutes = (integer)llGetSubString(stamp, 14, 15); + integer seconds = (integer)llGetSubString(stamp, 17, -2); + + year -= 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + + month = ~-month; + day = ~-day; + + integer days = (integer)(year * 365.25 + 0.25) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + day; + + return ( + days * 86400 + + hours * 3600 + + minutes * 60 + + seconds + ); +} + +// Compact Check +integer TimestampIsBefore(string a, string b) +{ + if((integer)llGetSubString(a, 8, 9) < (integer)llGetSubString(b, 8, 9)) return TRUE; + if((integer)llGetSubString(a, 11, 12) < (integer)llGetSubString(b, 11, 12)) return TRUE; + if((integer)llGetSubString(a, 14, 15) < (integer)llGetSubString(b, 14, 15)) return TRUE; + if((float)llGetSubString(a, 17, -2) < (float)llGetSubString(b, 17, -2)) return TRUE; + return FALSE; +} + +integer TimestampCompare(string a, string b) +{ + integer aDate = (integer)(llGetSubString(a, 0, 3) + llGetSubString(a, 5, 6) + llGetSubString(a, 8, 9)); + integer bDate = (integer)(llGetSubString(b, 0, 3) + llGetSubString(b, 5, 6) + llGetSubString(b, 8, 9)); + if(aDate < bDate) return -1; + else if(aDate > bDate) return 1; + float aTime = (float)(llGetSubString(a, 11, 12) + llGetSubString(a, 14, 15) + llGetSubString(a, 17, -1)); + float bTime = (float)(llGetSubString(b, 11, 12) + llGetSubString(b, 14, 15) + llGetSubString(b, 17, -1)); + llOwnerSay("aTime: " + (string)aTime + "; bTime: " + (string)bTime); + if(aTime < bTime) return -1; + else if(aTime > bTime) return 1; + return 0; +} + +// Milliseconds since beginning of month for timestamp +integer Timestamp2Millisec(string stamp) +{ + return (integer)llGetSubString(stamp, 8, 9) * 86400000 + // Days + (integer)llGetSubString(stamp, 11, 12) * 3600000 + // Hours + (integer)llGetSubString(stamp, 14, 15) * 60000 + // Minutes + llRound(((float)llGetSubString(stamp, 17, -2) * 1000.0)) // Seconds.Milliseconds + - 617316353; // Offset to fit between [-617316353,2147483547] +} + +// Seconds since timestamp epoch, per https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Seconds(string stamp) +{ + list parts = llParseString2List(stamp, ["-", "T", ":", "."], []); + integer year = llList2Integer(parts, 0) - 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + integer month = ~-llList2Integer(parts, 1); + return 86400 * (integer(year * 365.25 + 0.25) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + + (~-llList2Integer(parts, 2))) + + llList2Integer(parts, 3) * 3600 + + llList2Integer(parts, 4) * 60 + + llList2Integer(parts, 5); +} diff --git a/indra/alscript/tests/preprocessor/include/NexiiLSL/utilities.lsl b/indra/alscript/tests/preprocessor/include/NexiiLSL/utilities.lsl new file mode 100644 index 0000000000..17cdc908ea --- /dev/null +++ b/indra/alscript/tests/preprocessor/include/NexiiLSL/utilities.lsl @@ -0,0 +1,113 @@ +#define isSameOwner(identifier) llGetOwnerKey(identifier) == llGetOwner() +#define notSameOwner(identifier) llGetOwnerKey(identifier) != llGetOwner() + +// For displaying ordinal numbers like 1st, 2nd, 3rd, 20th, 21st, 22nd, etc. +string nth(integer value) +{ + value = value % 10; + if(value == 1) return "st"; + if(value == 2) return "nd"; + if(value == 3) return "rd"; + return "th"; +} + +// Returns the number of bytes a string would take up in UTF-8 encoding +integer stringBytes(string str) { + integer bytes; + integer length = llStringLength(str); + integer iterator; + for(; iterator < length; ++iterator) + { + integer byte = llOrd(str, iterator); + if(byte <= 0x7F) bytes += 1; + else if(byte <= 0x07FF) bytes += 2; + else if(byte <= 0xFFFF) bytes += 3; + else bytes += 4; + } + return bytes; +} + +// Significantly faster +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + + +// From https://wiki.secondlife.com/wiki/Efficient_Hex +string bits2nybbles(integer bits) { + integer lsn; // least significant nybble + string nybbles = ""; + do + nybbles = llGetSubString("0123456789abcdef", lsn = (bits & 0xF), lsn) + nybbles; + while (bits = (0xfffFFFF & (bits >> 4))); + return nybbles; +} + + +string AttachmentPointAsName(integer point) +{ + if(point == ATTACH_HEAD) return "Skull"; + if(point == ATTACH_NOSE) return "Nose"; + if(point == ATTACH_MOUTH) return "Mouth"; + if(point == ATTACH_FACE_TONGUE) return "Tongue"; + if(point == ATTACH_CHIN) return "Chin"; + if(point == ATTACH_FACE_JAW) return "Jaw"; + if(point == ATTACH_LEAR) return "Left Ear"; + if(point == ATTACH_REAR) return "Right Ear"; + if(point == ATTACH_FACE_LEAR) return "Alt Left Ear"; + if(point == ATTACH_FACE_REAR) return "Alt Right Ear"; + if(point == ATTACH_LEYE) return "Left Eye"; + if(point == ATTACH_REYE) return "Right Eye"; + if(point == ATTACH_FACE_LEYE) return "Alt Left Eye"; + if(point == ATTACH_FACE_REYE) return "Alt Right Eye"; + if(point == ATTACH_NECK) return "Neck"; + if(point == ATTACH_LSHOULDER) return "Left Shoulder"; + if(point == ATTACH_RSHOULDER) return "Right Shoulder"; + if(point == ATTACH_LUARM) return "L Upper Arm"; + if(point == ATTACH_RUARM) return "R Upper Arm"; + if(point == ATTACH_LLARM) return "L Lower Arm"; + if(point == ATTACH_RLARM) return "R Lower Arm"; + if(point == ATTACH_LHAND) return "Left Hand"; + if(point == ATTACH_RHAND) return "Right Hand"; + if(point == ATTACH_LHAND_RING1) return "Left Ring Finger"; + if(point == ATTACH_RHAND_RING1) return "Right Ring Finger"; + if(point == ATTACH_LWING) return "Left Wing"; + if(point == ATTACH_RWING) return "Right Wing"; + if(point == ATTACH_CHEST) return "Chest"; + if(point == ATTACH_LEFT_PEC) return "Left Pec"; + if(point == ATTACH_RIGHT_PEC) return "Right Pec"; + if(point == ATTACH_BELLY) return "Stomach"; + if(point == ATTACH_BACK) return "Spine"; + if(point == ATTACH_TAIL_BASE) return "Tail Base"; + if(point == ATTACH_TAIL_TIP) return "Tail Tip"; + if(point == ATTACH_AVATAR_CENTER) return "Avatar Center"; + if(point == ATTACH_PELVIS) return "Pelvis"; + if(point == ATTACH_GROIN) return "Groin"; + if(point == ATTACH_LHIP) return "Left Hip"; + if(point == ATTACH_RHIP) return "Right Hip"; + if(point == ATTACH_LULEG) return "L Upper Leg"; + if(point == ATTACH_RULEG) return "R Upper Leg"; + if(point == ATTACH_RLLEG) return "R Lower Leg"; + if(point == ATTACH_LLLEG) return "L Lower Leg"; + if(point == ATTACH_LFOOT) return "Left Foot"; + if(point == ATTACH_RFOOT) return "Right Foot"; + if(point == ATTACH_HIND_LFOOT) return "Left Hind Foot"; + if(point == ATTACH_HIND_RFOOT) return "Right Hind Foot"; + if(point == ATTACH_HUD_CENTER_2) return "HUD Center 2"; + if(point == ATTACH_HUD_TOP_RIGHT) return "HUD Top Right"; + if(point == ATTACH_HUD_TOP_CENTER) return "HUD Top"; + if(point == ATTACH_HUD_TOP_LEFT) return "HUD Top Left"; + if(point == ATTACH_HUD_CENTER_1) return "HUD Center"; + if(point == ATTACH_HUD_BOTTOM_LEFT) return "HUD Bottom Left"; + if(point == ATTACH_HUD_BOTTOM) return "HUD Bottom"; + if(point == ATTACH_HUD_BOTTOM_RIGHT) return "HUD Bottom Right"; + return (string)point; +} + + + + + + + diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri.lsl new file mode 100644 index 0000000000..e3f486278f --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri.lsl @@ -0,0 +1,22 @@ +// NexiiLSL's app-uri.lsl (include/NexiiLSL/LICENSE), each of its macros +// called: a type's name as a cast inside a body, and a call's argument +// that is itself a call. +#include "NexiiLSL/app-uri.lsl" + +default +{ + touch_start(integer count) + { + key toucher = llDetectedKey(0); + llOwnerSay(agentURI(toucher)); + llOwnerSay(agentAboutURI(toucher)); + llOwnerSay(regionURI(llGetRegionName())); + llOwnerSay(objectURI(llGetKey())); + llOwnerSay(fullObjectURI(llGetKey(), llGetObjectName(), llGetOwner())); + llOwnerSay(groupURI(llList2Key(llGetObjectDetails(llGetKey(), [OBJECT_GROUP]), 0))); + llOwnerSay(groupAboutURI(NULL_KEY)); + llOwnerSay(experienceURI(NULL_KEY)); + llOwnerSay(chatURI(-42, "hello there")); + llOwnerSay(wikiLink("https://wiki.secondlife.com", "the wiki")); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri_expected.lsl new file mode 100644 index 0000000000..d972e32bf0 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_app_uri_expected.lsl @@ -0,0 +1,32 @@ +// NexiiLSL's app-uri.lsl (include/NexiiLSL/LICENSE), each of its macros +// called: a type's name as a cast inside a body, and a call's argument +// that is itself a call. + + + + + + + + + + + + +default +{ + touch_start(integer count) + { + key toucher = llDetectedKey(0); + llOwnerSay("secondlife:///app/agent/" + ((string)(toucher)) + "/inspect"); + llOwnerSay("secondlife:///app/agent/" + ((string)(toucher)) + "/about"); + llOwnerSay("secondlife:///app/region/" + llEscapeURL(llGetRegionName())); + llOwnerSay("secondlife:///app/objectim/" + ((string)(llGetKey())) + "?name=" + llEscapeURL(llKey2Name(llGetKey())) + "&owner=" + (string)llGetOwnerKey(llGetKey())); + llOwnerSay("secondlife:///app/objectim/" + ((string)(llGetKey())) + "?name=" + llEscapeURL(llGetObjectName()) + "&owner=" + ((string)(llGetOwner()))); + llOwnerSay("secondlife:///app/group/" + ((string)(llList2Key(llGetObjectDetails(llGetKey(), [OBJECT_GROUP]), 0))) + "/inspect"); + llOwnerSay("secondlife:///app/group/" + ((string)(NULL_KEY)) + "/about"); + llOwnerSay("secondlife:///app/experience/" + ((string)(NULL_KEY)) + "/profile"); + llOwnerSay("secondlife:///app/chat/" + ((string)(-42)) + "/" + llEscapeURL("hello there")); + llOwnerSay("[" + "https://wiki.secondlife.com" + " " + "the wiki" + "]"); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types.lsl new file mode 100644 index 0000000000..5e9a37e1fc --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types.lsl @@ -0,0 +1,4 @@ +// NexiiLSL's damage-types.lsl (include/NexiiLSL/LICENSE): lists of strings +// as #defines indented inside a function, run over many lines by +// backslashes, each called with another of them, and casts inside them. +#include "NexiiLSL/damage-types.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types_expected.lsl new file mode 100644 index 0000000000..e58fc6431f --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_damage_types_expected.lsl @@ -0,0 +1,884 @@ +// NexiiLSL's damage-types.lsl (include/NexiiLSL/LICENSE): lists of strings +// as #defines indented inside a function, run over many lines by +// backslashes, each called with another of them, and casts inside them. + + + + + + + + + +// Returns name of the constant that identifies the damage type +string DamageTypeAsConstant(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "DAMAGE_TYPE_IMPACT"; + if(type == DAMAGE_TYPE_GENERIC) return "DAMAGE_TYPE_GENERIC"; + if(type == DAMAGE_TYPE_ACID) return "DAMAGE_TYPE_ACID"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "DAMAGE_TYPE_BLUDGEONING"; + if(type == DAMAGE_TYPE_COLD) return "DAMAGE_TYPE_COLD"; + if(type == DAMAGE_TYPE_ELECTRIC) return "DAMAGE_TYPE_ELECTRIC"; + if(type == DAMAGE_TYPE_FIRE) return "DAMAGE_TYPE_FIRE"; + if(type == DAMAGE_TYPE_FORCE) return "DAMAGE_TYPE_FORCE"; + if(type == DAMAGE_TYPE_NECROTIC) return "DAMAGE_TYPE_NECROTIC"; + if(type == DAMAGE_TYPE_PIERCING) return "DAMAGE_TYPE_PIERCING"; + if(type == DAMAGE_TYPE_POISON) return "DAMAGE_TYPE_POISON"; + if(type == DAMAGE_TYPE_PSYCHIC) return "DAMAGE_TYPE_PSYCHIC"; + if(type == DAMAGE_TYPE_RADIANT) return "DAMAGE_TYPE_RADIANT"; + if(type == DAMAGE_TYPE_SLASHING) return "DAMAGE_TYPE_SLASHING"; + if(type == DAMAGE_TYPE_SONIC) return "DAMAGE_TYPE_SONIC"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "DAMAGE_TYPE_EMOTIONAL"; + if(type == 100) return "DAMAGE_TYPE_MEDICAL"; + if(type == 101) return "DAMAGE_TYPE_REPAIR"; + if(type == 102) return "DAMAGE_TYPE_EXPLOSIVE"; + if(type == 103) return "DAMAGE_TYPE_CRUSHING"; + if(type == 104) return "DAMAGE_TYPE_ANTI_ARMOR"; + if(type == 105) return "DAMAGE_TYPE_SUFFOCATION"; + if(type == 106) return "DAMAGE_TYPE_REDEPLOY"; + return (string)type; +} + +// Returns damage type as a noun for use in sentences to describe the damage type +string DamageTypeAsNoun(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impact"; + if(type == DAMAGE_TYPE_GENERIC) return "generic"; + if(type == DAMAGE_TYPE_ACID) return "acid"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoning"; + if(type == DAMAGE_TYPE_COLD) return "cold"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electric"; + if(type == DAMAGE_TYPE_FIRE) return "fire"; + if(type == DAMAGE_TYPE_FORCE) return "force"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotic"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "piercing"; + if(type == DAMAGE_TYPE_POISON) return "poison"; + if(type == DAMAGE_TYPE_PSYCHIC) return "psychic"; + if(type == DAMAGE_TYPE_RADIANT) return "radiant"; + if(type == DAMAGE_TYPE_SLASHING) return "slashing"; + if(type == DAMAGE_TYPE_SONIC) return "sonic"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "emotional"; + if(type == 100) return "medical"; + if(type == 101) return "repair"; + if(type == 102) return "explosive"; + if(type == 103) return "crushing"; + if(type == 104) return "anti-armor"; + if(type == 105) return "suffocation"; + if(type == 106) return "redeploy"; + return "unknown"; +} + +// Returns damage type as a verb for use in sentences as how it was applied to a target +string DamageTypeAsVerb(integer type) +{ + if(type == DAMAGE_TYPE_IMPACT) return "impacted"; + if(type == DAMAGE_TYPE_GENERIC) return "damaged"; + if(type == DAMAGE_TYPE_ACID) return "corroded"; + if(type == DAMAGE_TYPE_BLUDGEONING) return "bludgeoned"; + if(type == DAMAGE_TYPE_COLD) return "frozen"; + if(type == DAMAGE_TYPE_ELECTRIC) return "electrocuted"; + if(type == DAMAGE_TYPE_FIRE) return "burnt"; + if(type == DAMAGE_TYPE_FORCE) return "smashed"; + if(type == DAMAGE_TYPE_NECROTIC) return "necrotised"; + if(type == DAMAGE_TYPE_PIERCING) return "shot"; // "pierced"; + if(type == DAMAGE_TYPE_POISON) return "poisoned"; + if(type == DAMAGE_TYPE_PSYCHIC) return "assaulted"; + if(type == DAMAGE_TYPE_RADIANT) return "raptured"; + if(type == DAMAGE_TYPE_SLASHING) return "slashed"; + if(type == DAMAGE_TYPE_SONIC) return "ruptured"; + if(type == DAMAGE_TYPE_EMOTIONAL) return "traumatised"; + if(type == 100) return "healed"; + if(type == 101) return "repaired"; + if(type == 102) return "exploded"; + if(type == 103) return "crushed"; + if(type == 104) return "punched through"; + if(type == 105) return "suffocated"; + if(type == 106) return "redeployed"; + return "damaged"; +} + +// Returns PRIM_TEXTURE params for rendering the damage type as an icon +list DamageTypeAsIcon(integer type) +{ + vector offset; integer variant = (integer)llFrand(32.0); + if(type == DAMAGE_TYPE_IMPACT) offset = ; + else if(type == DAMAGE_TYPE_GENERIC) offset = <4, 0, 0>; + else if(type == DAMAGE_TYPE_ACID) offset = <5, 0, 0>; + else if(type == DAMAGE_TYPE_BLUDGEONING) offset = <6, 0, 0>; + else if(type == DAMAGE_TYPE_COLD) offset = <7, 0, 0>; + else if(type == DAMAGE_TYPE_ELECTRIC) offset = <8, 0, 0>; + else if(type == DAMAGE_TYPE_FIRE) offset = <9, 0, 0>; + else if(type == DAMAGE_TYPE_FORCE) offset = <10, 0, 0>; + else if(type == DAMAGE_TYPE_NECROTIC) offset = <11, 0, 0>; + else if(type == DAMAGE_TYPE_PIERCING) offset = <12, 0, 0>; + else if(type == DAMAGE_TYPE_POISON) offset = <13, 0, 0>; + else if(type == DAMAGE_TYPE_PSYCHIC) offset = <14, 0, 0>; + else if(type == DAMAGE_TYPE_RADIANT) offset = <15, 0, 0>; + else if(type == DAMAGE_TYPE_SLASHING) offset = <0, 1, 0>; + else if(type == DAMAGE_TYPE_SONIC) offset = <1 + (variant % 2), 1, 0>; + else if(type == DAMAGE_TYPE_EMOTIONAL) offset = <3, 1, 0>; + else if(type == 100) offset = <4 + (variant % 2), 1, 0>; + else if(type == 101) offset = <6 + (variant % 2), 1, 0>; + else if(type == 102) offset = <8, 1, 0>; + else if(type == 103) offset = <9, 1, 0>; + else if(type == 104) offset = <10, 1, 0>; + else if(type == 105) offset = <12, 1, 0>; + else if(type == 106) offset = <8, 3, 0>; + + // These are not damage types but additional icons related to combat + else if(type == -100) offset = <0, 3, 0>; // Death + else if(type == -101) offset = <1, 3, 0>; // Enemy Death + else if(type == -102) offset = <2, 3, 0>; // Friendly Death + else if(type == -103) offset = <3, 3, 0>; // Object Death + else if(type == -104) offset = <4, 3, 0>; // Vehicle Death + else if(type == -105) offset = <5, 3, 0>; // Aircraft Death + else if(type == -106) offset = <6, 3, 0>; // Unconscious/Downed + else if(type == -107) offset = <7, 3, 0>; // Revive Requested + else if(type == -108) offset = <8, 3, 0>; // Being Revived + + /* + Potential other icons: + - Respawn, Redeploy + - Mech Death + - Shield Hit, Armor Hit, Critical Hit, EMP Hit + - Critical Hit, Enemy Critical Hit, Friendly Critical Hit + - Blocked Hit, Enemy Blocked Hit, Friendly Blocked Hit + - Missed Hit, Enemy Missed Hit, Friendly Missed Hit + - Civilian Down, Enemy Down, Friendly Down + - Kill Assist + - Headshot + - Target Locked, Target Lost + - Out of Ammo, Low Ammo, Reloading + - Overheated, Jammed, Hacked, Disarmed, Suppressed, Spotted, Invisible + - Invulnerable, Vulnerable + - Buffed, Debuffed + */ + + return [ + "2d053029-7baa-104d-fd8e-34b2112b4b78", + <128./2048., 128./512., 0>, + <(offset.x - 7.5) / 16, (1.5 - offset.y) / 4, 0>, + 0 + ]; +} + +string DamageTypeAsReason(integer type, key owner, key target) +{ + // Picks a uniformly random entry from a list. Avoids the modulo bias + // of llFrand(64.0) % N and means you never have to update counts when + // adding/removing lines. + + + // ============================================================ + // ATTACKER vs VICTIM (owner != target) + // ============================================================ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + // ============================================================ + // SELF-INFLICTED (owner == target) — TARGET-only phrasing + // ============================================================ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + string reason; + + // Redeploy is always a voluntary self-action, regardless of who is credited + if(type == 106) + { + reason = llList2String([ "TARGET redeployed to a new position", "TARGET returned to the deployment queue", "TARGET chose to redeploy", "TARGET cycled back to spawn", "TARGET withdrew for redeployment", "TARGET repositioned via redeploy", "TARGET abandoned the current position", "TARGET respawned elsewhere", "TARGET reset their tactical situation", "TARGET redeployed out of the combat zone", "TARGET took the express route back to spawn", "TARGET performed a tactical respawn", "TARGET sought better life choices via redeploy", "TARGET changed their mind — and location", "TARGET performed tactical disappearance", "TARGET reconsidered their surroundings", "TARGET pressed the redeploy button", "TARGET found a more survivable ZIP code", "TARGET left to spawn somewhere less explosive", "TARGET took a strategic timeout" ], (integer)llFrand((float)llGetListLength([ "TARGET redeployed to a new position", "TARGET returned to the deployment queue", "TARGET chose to redeploy", "TARGET cycled back to spawn", "TARGET withdrew for redeployment", "TARGET repositioned via redeploy", "TARGET abandoned the current position", "TARGET respawned elsewhere", "TARGET reset their tactical situation", "TARGET redeployed out of the combat zone", "TARGET took the express route back to spawn", "TARGET performed a tactical respawn", "TARGET sought better life choices via redeploy", "TARGET changed their mind — and location", "TARGET performed tactical disappearance", "TARGET reconsidered their surroundings", "TARGET pressed the redeploy button", "TARGET found a more survivable ZIP code", "TARGET left to spawn somewhere less explosive", "TARGET took a strategic timeout" ]))); + } + else if(owner == target) + { + // Self-inflicted: TARGET-only phrasing, no attacker credit + if(type == DAMAGE_TYPE_IMPACT) reason = llList2String([ "TARGET punched the ground. The ground punched back", "TARGET tested the structural integrity of a nearby wall", "TARGET tested the structural integrity of a nearby floor", "TARGET tested the structural integrity of a nearby ceiling", "TARGET discovered the upper limit of their movement enhancer", "TARGET achieved terminal mobility", "TARGET mistook momentum for a survival strategy", "TARGET lost a close-quarters engagement with the terrain", "TARGET deployed directly into solid matter", "TARGET accelerated confidently into disaster", "TARGET's movement tech delivered exactly as advertised", "TARGET learned that velocity has consequences", "TARGET attempted advanced maneuvering without adult supervision", "TARGET converted speed into an immediate medical issue", "TARGET overshot the objective by several meters and one life", "TARGET used tactical sprint offensively against themselves", "TARGET was outplayed by basic map geometry", "TARGET trusted the jump pack. The jump pack disagreed", "TARGET discovered an undocumented interaction between boosters and concrete", "TARGET briefly achieved flight before remembering they weren't aviation-capable", "TARGET's landing request was denied by physics", "TARGET found the map boundary emotionally and physically", "TARGET turned mobility equipment into a kinetic weapon against TARGET", "TARGET's combat stim demanded speed; the wall accepted payment", "TARGET exceeded recommended operating velocity", "TARGET weaponized their own momentum", "TARGET slammed into cover with unnecessary enthusiasm", "TARGET's parkour certification has been revoked", "TARGET hit mach jesus and forgot the braking phase", "TARGET went from combatant to projectile", "TARGET speedran the respawn screen", "TARGET demonstrated why boosters come with warning labels", "TARGET challenged Newtonian mechanics and lost unanimously", "TARGET's movement enhancer secured a confirmed kill", "TARGET achieved excellent airtime and poor decision-making", "TARGET discovered a new route directly into the ground", "TARGET attempted vertical gameplay and received horizontal consequences", "TARGET became one with nearby infrastructure" ], (integer)llFrand((float)llGetListLength([ "TARGET punched the ground. The ground punched back", "TARGET tested the structural integrity of a nearby wall", "TARGET tested the structural integrity of a nearby floor", "TARGET tested the structural integrity of a nearby ceiling", "TARGET discovered the upper limit of their movement enhancer", "TARGET achieved terminal mobility", "TARGET mistook momentum for a survival strategy", "TARGET lost a close-quarters engagement with the terrain", "TARGET deployed directly into solid matter", "TARGET accelerated confidently into disaster", "TARGET's movement tech delivered exactly as advertised", "TARGET learned that velocity has consequences", "TARGET attempted advanced maneuvering without adult supervision", "TARGET converted speed into an immediate medical issue", "TARGET overshot the objective by several meters and one life", "TARGET used tactical sprint offensively against themselves", "TARGET was outplayed by basic map geometry", "TARGET trusted the jump pack. The jump pack disagreed", "TARGET discovered an undocumented interaction between boosters and concrete", "TARGET briefly achieved flight before remembering they weren't aviation-capable", "TARGET's landing request was denied by physics", "TARGET found the map boundary emotionally and physically", "TARGET turned mobility equipment into a kinetic weapon against TARGET", "TARGET's combat stim demanded speed; the wall accepted payment", "TARGET exceeded recommended operating velocity", "TARGET weaponized their own momentum", "TARGET slammed into cover with unnecessary enthusiasm", "TARGET's parkour certification has been revoked", "TARGET hit mach jesus and forgot the braking phase", "TARGET went from combatant to projectile", "TARGET speedran the respawn screen", "TARGET demonstrated why boosters come with warning labels", "TARGET challenged Newtonian mechanics and lost unanimously", "TARGET's movement enhancer secured a confirmed kill", "TARGET achieved excellent airtime and poor decision-making", "TARGET discovered a new route directly into the ground", "TARGET attempted vertical gameplay and received horizontal consequences", "TARGET became one with nearby infrastructure" ]))); + else if(type == DAMAGE_TYPE_GENERIC) reason = llList2String([ "TARGET eliminated TARGET. Efficient", "TARGET cut out the middleman and killed themselves", "TARGET found the enemy. It was TARGET", "TARGET secured a confirmed kill on TARGET", "TARGET defeated their greatest opponent: TARGET", "TARGET needed no assistance dying", "TARGET took themselves out of the fight", "TARGET saved the enemy some ammunition" ], (integer)llFrand((float)llGetListLength([ "TARGET eliminated TARGET. Efficient", "TARGET cut out the middleman and killed themselves", "TARGET found the enemy. It was TARGET", "TARGET secured a confirmed kill on TARGET", "TARGET defeated their greatest opponent: TARGET", "TARGET needed no assistance dying", "TARGET took themselves out of the fight", "TARGET saved the enemy some ammunition" ]))); + else if(type == DAMAGE_TYPE_ACID) reason = llList2String([ "TARGET dissolved in their own corrosives", "TARGET learned that acid doesn't check IFF", "TARGET became the test subject of their own chemistry experiment", "TARGET stored the acid incorrectly: on themselves", "TARGET handled corrosives with confidence instead of gloves", "TARGET's chemical weapon chose violence locally", "TARGET self-liquefied", "TARGET reduced their own gear, and TARGET, to sludge" ], (integer)llFrand((float)llGetListLength([ "TARGET dissolved in their own corrosives", "TARGET learned that acid doesn't check IFF", "TARGET became the test subject of their own chemistry experiment", "TARGET stored the acid incorrectly: on themselves", "TARGET handled corrosives with confidence instead of gloves", "TARGET's chemical weapon chose violence locally", "TARGET self-liquefied", "TARGET reduced their own gear, and TARGET, to sludge" ]))); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = llList2String([ "TARGET beat themselves to the punch. Fatally", "TARGET delivered blunt trauma to the nearest available skull: their own", "TARGET lost a fistfight against their own equipment", "TARGET swung hard and connected with TARGET", "TARGET self-administered percussive maintenance", "TARGET was somehow on both ends of the hammer", "TARGET flattened the closest combatant: TARGET", "TARGET hammered themselves into the dirt unassisted" ], (integer)llFrand((float)llGetListLength([ "TARGET beat themselves to the punch. Fatally", "TARGET delivered blunt trauma to the nearest available skull: their own", "TARGET lost a fistfight against their own equipment", "TARGET swung hard and connected with TARGET", "TARGET self-administered percussive maintenance", "TARGET was somehow on both ends of the hammer", "TARGET flattened the closest combatant: TARGET", "TARGET hammered themselves into the dirt unassisted" ]))); + else if(type == DAMAGE_TYPE_COLD) reason = llList2String([ "TARGET froze themselves solid", "TARGET stress-tested their own cryogenics from the inside", "TARGET achieved self-refrigeration", "TARGET forgot which end of the cryo weapon was the cold one", "TARGET preserved themselves for future generations", "TARGET lost the heat war against TARGET", "TARGET turned themselves into a frozen casualty", "TARGET weaponized winter against the only person in range: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET froze themselves solid", "TARGET stress-tested their own cryogenics from the inside", "TARGET achieved self-refrigeration", "TARGET forgot which end of the cryo weapon was the cold one", "TARGET preserved themselves for future generations", "TARGET lost the heat war against TARGET", "TARGET turned themselves into a frozen casualty", "TARGET weaponized winter against the only person in range: TARGET" ]))); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = llList2String([ "TARGET electrocuted themselves", "TARGET completed the circuit personally", "TARGET discovered they were the path of least resistance", "TARGET grounded themselves. Permanently", "TARGET conducted a one-person electrical safety demonstration", "TARGET's high-voltage solution solved TARGET", "TARGET short-circuited their own nervous system", "TARGET fried the nearest conductor: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET electrocuted themselves", "TARGET completed the circuit personally", "TARGET discovered they were the path of least resistance", "TARGET grounded themselves. Permanently", "TARGET conducted a one-person electrical safety demonstration", "TARGET's high-voltage solution solved TARGET", "TARGET short-circuited their own nervous system", "TARGET fried the nearest conductor: TARGET" ]))); + else if(type == DAMAGE_TYPE_FIRE) reason = llList2String([ "TARGET self-ignited", "TARGET stood in their own fire and called it warmth", "TARGET deployed incendiaries at extremely personal range", "TARGET became their own thermal signature", "TARGET played with fire and lost custody", "TARGET achieved self-cremation ahead of schedule", "TARGET lost the thermal exchange with TARGET", "TARGET's flames showed no brand loyalty" ], (integer)llFrand((float)llGetListLength([ "TARGET self-ignited", "TARGET stood in their own fire and called it warmth", "TARGET deployed incendiaries at extremely personal range", "TARGET became their own thermal signature", "TARGET played with fire and lost custody", "TARGET achieved self-cremation ahead of schedule", "TARGET lost the thermal exchange with TARGET", "TARGET's flames showed no brand loyalty" ]))); + else if(type == DAMAGE_TYPE_FORCE) reason = llList2String([ "TARGET blasted themselves apart", "TARGET stood at the business end of their own shockwave", "TARGET applied devastating force in the wrong direction", "TARGET freight-trained TARGET", "TARGET violently displaced themselves", "TARGET's raw power needed a target. TARGET volunteered", "TARGET blew themselves clean off their own position", "TARGET found their own impact unsurvivable" ], (integer)llFrand((float)llGetListLength([ "TARGET blasted themselves apart", "TARGET stood at the business end of their own shockwave", "TARGET applied devastating force in the wrong direction", "TARGET freight-trained TARGET", "TARGET violently displaced themselves", "TARGET's raw power needed a target. TARGET volunteered", "TARGET blew themselves clean off their own position", "TARGET found their own impact unsurvivable" ]))); + else if(type == DAMAGE_TYPE_NECROTIC) reason = llList2String([ "TARGET drained their own life force", "TARGET accelerated their own decay considerably", "TARGET reduced themselves to a husk", "TARGET's life force resigned without notice", "TARGET consumed their own vitality. Bold strategy", "TARGET brought slow death to the nearest lifeform: TARGET", "TARGET faded themselves out", "TARGET left TARGET biologically ruined" ], (integer)llFrand((float)llGetListLength([ "TARGET drained their own life force", "TARGET accelerated their own decay considerably", "TARGET reduced themselves to a husk", "TARGET's life force resigned without notice", "TARGET consumed their own vitality. Bold strategy", "TARGET brought slow death to the nearest lifeform: TARGET", "TARGET faded themselves out", "TARGET left TARGET biologically ruined" ]))); + else if(type == DAMAGE_TYPE_PIERCING) reason = llList2String([ "TARGET shot themselves", "TARGET checked if the gun was loaded the hard way", "TARGET caught a burst from a familiar rifle: their own", "TARGET lost a firefight with zero other participants", "TARGET demonstrated muzzle awareness by counterexample", "TARGET filled themselves with lead", "TARGET ventilated TARGET", "TARGET landed center-mass hits on the wrong center mass" ], (integer)llFrand((float)llGetListLength([ "TARGET shot themselves", "TARGET checked if the gun was loaded the hard way", "TARGET caught a burst from a familiar rifle: their own", "TARGET lost a firefight with zero other participants", "TARGET demonstrated muzzle awareness by counterexample", "TARGET filled themselves with lead", "TARGET ventilated TARGET", "TARGET landed center-mass hits on the wrong center mass" ]))); + else if(type == DAMAGE_TYPE_POISON) reason = llList2String([ "TARGET poisoned themselves", "TARGET taste-tested their own toxins", "TARGET confused the antidote with the dosage", "TARGET's chemical weapon worked exactly once, on TARGET", "TARGET self-administered a lethal dose", "TARGET trusted their own labeling system incorrectly", "TARGET's body gave up after consulting with TARGET", "TARGET delivered a toxic end to TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET poisoned themselves", "TARGET taste-tested their own toxins", "TARGET confused the antidote with the dosage", "TARGET's chemical weapon worked exactly once, on TARGET", "TARGET self-administered a lethal dose", "TARGET trusted their own labeling system incorrectly", "TARGET's body gave up after consulting with TARGET", "TARGET delivered a toxic end to TARGET" ]))); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = llList2String([ "TARGET thought themselves to death", "TARGET lost an argument inside their own head", "TARGET's mind overloaded itself", "TARGET overthought a survivable situation. Fatally", "TARGET was psychologically neutralized by TARGET", "TARGET won the cognitive battle and also lost it", "TARGET shattered their own mind unassisted", "TARGET gave TARGET more than TARGET could process" ], (integer)llFrand((float)llGetListLength([ "TARGET thought themselves to death", "TARGET lost an argument inside their own head", "TARGET's mind overloaded itself", "TARGET overthought a survivable situation. Fatally", "TARGET was psychologically neutralized by TARGET", "TARGET won the cognitive battle and also lost it", "TARGET shattered their own mind unassisted", "TARGET gave TARGET more than TARGET could process" ]))); + else if(type == DAMAGE_TYPE_RADIANT) reason = llList2String([ "TARGET irradiated themselves", "TARGET stood inside their own energy discharge", "TARGET glowed with self-confidence. Then just glowed", "TARGET ignored the safe operating distance of their own weapon", "TARGET achieved self-illumination. Briefly", "TARGET exposed themselves to lethal radiation. Their own", "TARGET outshone TARGET fatally", "TARGET annihilated the closest energy signature: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET irradiated themselves", "TARGET stood inside their own energy discharge", "TARGET glowed with self-confidence. Then just glowed", "TARGET ignored the safe operating distance of their own weapon", "TARGET achieved self-illumination. Briefly", "TARGET exposed themselves to lethal radiation. Their own", "TARGET outshone TARGET fatally", "TARGET annihilated the closest energy signature: TARGET" ]))); + else if(type == DAMAGE_TYPE_SLASHING) reason = llList2String([ "TARGET cut themselves down", "TARGET lost a knife fight against TARGET", "TARGET found the sharp end of their own blade", "TARGET demonstrated blade safety by counterexample", "TARGET sliced through the nearest combatant: TARGET", "TARGET made short work of themselves", "TARGET opened TARGET up. No assistance required", "TARGET's blade showed no loyalty" ], (integer)llFrand((float)llGetListLength([ "TARGET cut themselves down", "TARGET lost a knife fight against TARGET", "TARGET found the sharp end of their own blade", "TARGET demonstrated blade safety by counterexample", "TARGET sliced through the nearest combatant: TARGET", "TARGET made short work of themselves", "TARGET opened TARGET up. No assistance required", "TARGET's blade showed no loyalty" ]))); + else if(type == DAMAGE_TYPE_SONIC) reason = llList2String([ "TARGET deafened themselves to death", "TARGET stood inside their own pressure wave", "TARGET lost the volume war against TARGET", "TARGET turned the acoustic weapon up to eleven while holding it", "TARGET concussed themselves with their own bass drop", "TARGET's sonic blast didn't discriminate", "TARGET collapsed their own senses", "TARGET weaponized sound against the closest pair of ears: their own" ], (integer)llFrand((float)llGetListLength([ "TARGET deafened themselves to death", "TARGET stood inside their own pressure wave", "TARGET lost the volume war against TARGET", "TARGET turned the acoustic weapon up to eleven while holding it", "TARGET concussed themselves with their own bass drop", "TARGET's sonic blast didn't discriminate", "TARGET collapsed their own senses", "TARGET weaponized sound against the closest pair of ears: their own" ]))); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = llList2String([ "TARGET's morale collapsed under TARGET", "TARGET lost the psychological war with themselves", "TARGET took the loss personally. From themselves", "TARGET crushed their own confidence fatally", "TARGET broke their own spirit before the enemy could", "TARGET was their own harshest critic. Lethally", "TARGET applied pressure TARGET never recovered from", "TARGET emotionally devastated TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET's morale collapsed under TARGET", "TARGET lost the psychological war with themselves", "TARGET took the loss personally. From themselves", "TARGET crushed their own confidence fatally", "TARGET broke their own spirit before the enemy could", "TARGET was their own harshest critic. Lethally", "TARGET applied pressure TARGET never recovered from", "TARGET emotionally devastated TARGET" ]))); + else if(type == 100) reason = llList2String([ "TARGET self-medicated into the afterlife", "TARGET's self-treatment proved terminal", "TARGET read the medkit instructions posthumously", "TARGET healed themselves to death", "TARGET trusted their own medical credentials incorrectly", "TARGET flatlined under their own care", "TARGET gave TARGET one treatment too many", "TARGET's bedside manner killed the only patient available: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET self-medicated into the afterlife", "TARGET's self-treatment proved terminal", "TARGET read the medkit instructions posthumously", "TARGET healed themselves to death", "TARGET trusted their own medical credentials incorrectly", "TARGET flatlined under their own care", "TARGET gave TARGET one treatment too many", "TARGET's bedside manner killed the only patient available: TARGET" ]))); + else if(type == 101) reason = llList2String([ "TARGET repaired themselves into catastrophic failure", "TARGET performed rapid unscheduled self-disassembly", "TARGET overclocked themselves to destruction", "TARGET voided their own warranty", "TARGET's self-maintenance proved unsurvivable", "TARGET pushed TARGET beyond operational limits", "TARGET turned self-maintenance into a confirmed kill", "TARGET rendered TARGET permanently nonfunctional" ], (integer)llFrand((float)llGetListLength([ "TARGET repaired themselves into catastrophic failure", "TARGET performed rapid unscheduled self-disassembly", "TARGET overclocked themselves to destruction", "TARGET voided their own warranty", "TARGET's self-maintenance proved unsurvivable", "TARGET pushed TARGET beyond operational limits", "TARGET turned self-maintenance into a confirmed kill", "TARGET rendered TARGET permanently nonfunctional" ]))); + else if(type == 102) reason = llList2String([ "TARGET held the grenade slightly too long", "TARGET stood in their own blast radius", "TARGET cooked a grenade past the recommended doneness", "TARGET became the epicenter", "TARGET's ordnance arrived before TARGET left", "TARGET reduced themselves to debris", "TARGET couldn't outrun their own explosives", "TARGET ate their own grenade. Chef's kiss" ], (integer)llFrand((float)llGetListLength([ "TARGET held the grenade slightly too long", "TARGET stood in their own blast radius", "TARGET cooked a grenade past the recommended doneness", "TARGET became the epicenter", "TARGET's ordnance arrived before TARGET left", "TARGET reduced themselves to debris", "TARGET couldn't outrun their own explosives", "TARGET ate their own grenade. Chef's kiss" ]))); + else if(type == 103) reason = llList2String([ "TARGET crushed themselves", "TARGET was flattened by their own equipment", "TARGET ran a compression test on TARGET. Both sides failed", "TARGET parked something heavy on top of themselves", "TARGET folded under pressure of their own making", "TARGET compacted themselves for easy storage", "TARGET reduced TARGET to scrap", "TARGET turned pressure into a weapon against the only one in range: TARGET" ], (integer)llFrand((float)llGetListLength([ "TARGET crushed themselves", "TARGET was flattened by their own equipment", "TARGET ran a compression test on TARGET. Both sides failed", "TARGET parked something heavy on top of themselves", "TARGET folded under pressure of their own making", "TARGET compacted themselves for easy storage", "TARGET reduced TARGET to scrap", "TARGET turned pressure into a weapon against the only one in range: TARGET" ]))); + else if(type == 104) reason = llList2String([ "TARGET penetrated their own armor", "TARGET proved their own armor was a suggestion", "TARGET tested AP rounds on the nearest armor: their own", "TARGET turned their armor into a liability personally", "TARGET cracked themselves open with anti-vehicle fire", "TARGET won and lost the armor check simultaneously", "TARGET's penetrators went straight through TARGET", "TARGET gutted TARGET with heavy ordnance. Impressive angle" ], (integer)llFrand((float)llGetListLength([ "TARGET penetrated their own armor", "TARGET proved their own armor was a suggestion", "TARGET tested AP rounds on the nearest armor: their own", "TARGET turned their armor into a liability personally", "TARGET cracked themselves open with anti-vehicle fire", "TARGET won and lost the armor check simultaneously", "TARGET's penetrators went straight through TARGET", "TARGET gutted TARGET with heavy ordnance. Impressive angle" ]))); + else if(type == 105) reason = llList2String([ "TARGET forgot breathing was mandatory", "TARGET ran out of air with no help from anyone", "TARGET revoked their own oxygen access", "TARGET held their breath competitively against themselves", "TARGET treated oxygen as optional", "TARGET asphyxiated unassisted", "TARGET removed breathing from their own options", "TARGET turned atmosphere into a privilege TARGET didn't have" ], (integer)llFrand((float)llGetListLength([ "TARGET forgot breathing was mandatory", "TARGET ran out of air with no help from anyone", "TARGET revoked their own oxygen access", "TARGET held their breath competitively against themselves", "TARGET treated oxygen as optional", "TARGET asphyxiated unassisted", "TARGET removed breathing from their own options", "TARGET turned atmosphere into a privilege TARGET didn't have" ]))); + else if(type == -100) reason = "TARGET team-killed the only teammate within reach: TARGET"; + else reason = "TARGET was killed by TARGET's own " + DamageTypeAsNoun(type) + " damage. Impressive"; + } + else + { + // Attacker vs victim: OWNER-first phrasing + if(type == DAMAGE_TYPE_IMPACT) reason = llList2String([ "OWNER knocked TARGET into a wall", "OWNER slammed TARGET into cover", "OWNER sent TARGET crashing into terrain", "OWNER introduced TARGET to the ground at speed", "OWNER forced TARGET into a fatal collision", "OWNER's fire cancelled TARGET's momentum permanently", "OWNER knocked TARGET off balance and into solid concrete", "OWNER pressured TARGET into pancaking against the environment", "OWNER turned gravity against TARGET", "OWNER arranged a high-speed argument between TARGET and physics. Physics won" ], (integer)llFrand((float)llGetListLength([ "OWNER knocked TARGET into a wall", "OWNER slammed TARGET into cover", "OWNER sent TARGET crashing into terrain", "OWNER introduced TARGET to the ground at speed", "OWNER forced TARGET into a fatal collision", "OWNER's fire cancelled TARGET's momentum permanently", "OWNER knocked TARGET off balance and into solid concrete", "OWNER pressured TARGET into pancaking against the environment", "OWNER turned gravity against TARGET", "OWNER arranged a high-speed argument between TARGET and physics. Physics won" ]))); + else if(type == DAMAGE_TYPE_GENERIC) reason = llList2String([ "OWNER killed TARGET", "OWNER eliminated TARGET", "OWNER took down TARGET", "OWNER neutralized TARGET", "OWNER removed TARGET from the battlefield", "OWNER put TARGET down", "OWNER dropped TARGET", "OWNER defeated TARGET", "OWNER finished TARGET", "OWNER took TARGET out of the fight" ], (integer)llFrand((float)llGetListLength([ "OWNER killed TARGET", "OWNER eliminated TARGET", "OWNER took down TARGET", "OWNER neutralized TARGET", "OWNER removed TARGET from the battlefield", "OWNER put TARGET down", "OWNER dropped TARGET", "OWNER defeated TARGET", "OWNER finished TARGET", "OWNER took TARGET out of the fight" ]))); + else if(type == DAMAGE_TYPE_ACID) reason = llList2String([ "OWNER dissolved TARGET with corrosives", "OWNER melted TARGET with a chemical attack", "OWNER splashed TARGET with lethal acid", "OWNER delivered a corrosive payload directly to TARGET", "OWNER reduced TARGET's gear to sludge", "OWNER chemically erased TARGET", "OWNER turned chemistry into a weapon against TARGET", "OWNER's acid attack proved too much for TARGET", "OWNER corroded TARGET beyond repair", "OWNER liquefied TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER dissolved TARGET with corrosives", "OWNER melted TARGET with a chemical attack", "OWNER splashed TARGET with lethal acid", "OWNER delivered a corrosive payload directly to TARGET", "OWNER reduced TARGET's gear to sludge", "OWNER chemically erased TARGET", "OWNER turned chemistry into a weapon against TARGET", "OWNER's acid attack proved too much for TARGET", "OWNER corroded TARGET beyond repair", "OWNER liquefied TARGET" ]))); + else if(type == DAMAGE_TYPE_BLUDGEONING) reason = llList2String([ "OWNER beat TARGET down", "OWNER crushed TARGET with blunt force", "OWNER smashed TARGET into submission", "OWNER landed a fatal blunt hit on TARGET", "OWNER battered TARGET into the dirt", "OWNER flattened TARGET", "OWNER hammered TARGET", "OWNER's heavy impact ended TARGET", "OWNER delivered overwhelming blunt trauma to TARGET", "OWNER broke TARGET with a single strike" ], (integer)llFrand((float)llGetListLength([ "OWNER beat TARGET down", "OWNER crushed TARGET with blunt force", "OWNER smashed TARGET into submission", "OWNER landed a fatal blunt hit on TARGET", "OWNER battered TARGET into the dirt", "OWNER flattened TARGET", "OWNER hammered TARGET", "OWNER's heavy impact ended TARGET", "OWNER delivered overwhelming blunt trauma to TARGET", "OWNER broke TARGET with a single strike" ]))); + else if(type == DAMAGE_TYPE_COLD) reason = llList2String([ "OWNER froze TARGET solid", "OWNER's cryogenic attack claimed TARGET", "OWNER chilled TARGET beyond recovery", "OWNER iced TARGET over", "OWNER turned TARGET into a frozen casualty", "OWNER subjected TARGET to lethal cold exposure", "OWNER locked TARGET down with extreme cold", "OWNER left TARGET frozen mid-fight", "OWNER weaponized winter against TARGET", "OWNER won the heat war against TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER froze TARGET solid", "OWNER's cryogenic attack claimed TARGET", "OWNER chilled TARGET beyond recovery", "OWNER iced TARGET over", "OWNER turned TARGET into a frozen casualty", "OWNER subjected TARGET to lethal cold exposure", "OWNER locked TARGET down with extreme cold", "OWNER left TARGET frozen mid-fight", "OWNER weaponized winter against TARGET", "OWNER won the heat war against TARGET" ]))); + else if(type == DAMAGE_TYPE_ELECTRIC) reason = llList2String([ "OWNER electrocuted TARGET", "OWNER shocked TARGET to death", "OWNER overloaded TARGET with electricity", "OWNER delivered lethal voltage to TARGET", "OWNER lit TARGET up with electrical discharge", "OWNER shut down TARGET's nervous system", "OWNER short-circuited TARGET", "OWNER applied a high-voltage solution to TARGET", "OWNER fried TARGET", "OWNER made TARGET spark out" ], (integer)llFrand((float)llGetListLength([ "OWNER electrocuted TARGET", "OWNER shocked TARGET to death", "OWNER overloaded TARGET with electricity", "OWNER delivered lethal voltage to TARGET", "OWNER lit TARGET up with electrical discharge", "OWNER shut down TARGET's nervous system", "OWNER short-circuited TARGET", "OWNER applied a high-voltage solution to TARGET", "OWNER fried TARGET", "OWNER made TARGET spark out" ]))); + else if(type == DAMAGE_TYPE_FIRE) reason = llList2String([ "OWNER burned TARGET alive", "OWNER incinerated TARGET", "OWNER engulfed TARGET in flames", "OWNER cooked TARGET with incendiaries", "OWNER reduced TARGET to ashes", "OWNER set TARGET on fire. Permanently", "OWNER scorched TARGET off the battlefield", "OWNER won the thermal exchange with TARGET", "OWNER roasted TARGET", "OWNER's flames consumed TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER burned TARGET alive", "OWNER incinerated TARGET", "OWNER engulfed TARGET in flames", "OWNER cooked TARGET with incendiaries", "OWNER reduced TARGET to ashes", "OWNER set TARGET on fire. Permanently", "OWNER scorched TARGET off the battlefield", "OWNER won the thermal exchange with TARGET", "OWNER roasted TARGET", "OWNER's flames consumed TARGET" ]))); + else if(type == DAMAGE_TYPE_FORCE) reason = llList2String([ "OWNER blasted TARGET apart", "OWNER launched TARGET", "OWNER overwhelmed TARGET with raw force", "OWNER hit TARGET with a fatal shockwave", "OWNER hit TARGET with devastating force", "OWNER's impact proved unsurvivable for TARGET", "OWNER violently displaced TARGET", "OWNER freight-trained TARGET", "OWNER crushed TARGET with concussive power", "OWNER blew TARGET clean off their position" ], (integer)llFrand((float)llGetListLength([ "OWNER blasted TARGET apart", "OWNER launched TARGET", "OWNER overwhelmed TARGET with raw force", "OWNER hit TARGET with a fatal shockwave", "OWNER hit TARGET with devastating force", "OWNER's impact proved unsurvivable for TARGET", "OWNER violently displaced TARGET", "OWNER freight-trained TARGET", "OWNER crushed TARGET with concussive power", "OWNER blew TARGET clean off their position" ]))); + else if(type == DAMAGE_TYPE_NECROTIC) reason = llList2String([ "OWNER drained the life from TARGET", "OWNER withered TARGET away", "OWNER consumed TARGET's vitality", "OWNER accelerated TARGET's decay considerably", "OWNER left TARGET biologically ruined", "OWNER's lethal touch faded TARGET out", "OWNER reduced TARGET to a husk", "OWNER outlasted TARGET's life force", "OWNER brought slow death to TARGET", "OWNER's necrotic damage claimed TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER drained the life from TARGET", "OWNER withered TARGET away", "OWNER consumed TARGET's vitality", "OWNER accelerated TARGET's decay considerably", "OWNER left TARGET biologically ruined", "OWNER's lethal touch faded TARGET out", "OWNER reduced TARGET to a husk", "OWNER outlasted TARGET's life force", "OWNER brought slow death to TARGET", "OWNER's necrotic damage claimed TARGET" ]))); + else if(type == DAMAGE_TYPE_PIERCING) reason = llList2String([ "OWNER riddled TARGET with bullets", "OWNER cut TARGET down with rifle fire", "OWNER stitched TARGET with automatic fire", "OWNER put a burst into TARGET", "OWNER perforated TARGET with incoming rounds", "OWNER drilled TARGET with gunfire", "OWNER landed center-mass hits on TARGET", "OWNER won the firefight against TARGET", "OWNER filled TARGET with lead", "OWNER ventilated TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER riddled TARGET with bullets", "OWNER cut TARGET down with rifle fire", "OWNER stitched TARGET with automatic fire", "OWNER put a burst into TARGET", "OWNER perforated TARGET with incoming rounds", "OWNER drilled TARGET with gunfire", "OWNER landed center-mass hits on TARGET", "OWNER won the firefight against TARGET", "OWNER filled TARGET with lead", "OWNER ventilated TARGET" ]))); + else if(type == DAMAGE_TYPE_POISON) reason = llList2String([ "OWNER poisoned TARGET", "OWNER's toxins claimed TARGET", "OWNER contaminated TARGET fatally", "OWNER chemically deteriorated TARGET", "OWNER dosed TARGET with lethal poison", "OWNER convinced TARGET's body to give up", "OWNER weaponized toxins against TARGET", "OWNER's poison ran its full course on TARGET", "OWNER delivered a toxic end to TARGET", "OWNER gave TARGET a fatal chemical exposure" ], (integer)llFrand((float)llGetListLength([ "OWNER poisoned TARGET", "OWNER's toxins claimed TARGET", "OWNER contaminated TARGET fatally", "OWNER chemically deteriorated TARGET", "OWNER dosed TARGET with lethal poison", "OWNER convinced TARGET's body to give up", "OWNER weaponized toxins against TARGET", "OWNER's poison ran its full course on TARGET", "OWNER delivered a toxic end to TARGET", "OWNER gave TARGET a fatal chemical exposure" ]))); + else if(type == DAMAGE_TYPE_PSYCHIC) reason = llList2String([ "OWNER shattered TARGET's mind", "OWNER caused TARGET's complete mental collapse", "OWNER overwhelmed TARGET psychologically", "OWNER out-thought TARGET fatally", "OWNER broke TARGET mentally", "OWNER won the cognitive battle against TARGET", "OWNER overloaded TARGET's mind", "OWNER psychologically neutralized TARGET", "OWNER destabilized TARGET completely", "OWNER gave TARGET more than TARGET could process" ], (integer)llFrand((float)llGetListLength([ "OWNER shattered TARGET's mind", "OWNER caused TARGET's complete mental collapse", "OWNER overwhelmed TARGET psychologically", "OWNER out-thought TARGET fatally", "OWNER broke TARGET mentally", "OWNER won the cognitive battle against TARGET", "OWNER overloaded TARGET's mind", "OWNER psychologically neutralized TARGET", "OWNER destabilized TARGET completely", "OWNER gave TARGET more than TARGET could process" ]))); + else if(type == DAMAGE_TYPE_RADIANT) reason = llList2String([ "OWNER overwhelmed TARGET with radiant energy", "OWNER burned TARGET with radiation", "OWNER blasted TARGET with destructive light", "OWNER made TARGET glow. Briefly", "OWNER irradiated TARGET fatally", "OWNER's energy discharge claimed TARGET", "OWNER seared TARGET with pure energy", "OWNER outshone TARGET fatally", "OWNER exposed TARGET to lethal radiation", "OWNER annihilated TARGET with raw energy" ], (integer)llFrand((float)llGetListLength([ "OWNER overwhelmed TARGET with radiant energy", "OWNER burned TARGET with radiation", "OWNER blasted TARGET with destructive light", "OWNER made TARGET glow. Briefly", "OWNER irradiated TARGET fatally", "OWNER's energy discharge claimed TARGET", "OWNER seared TARGET with pure energy", "OWNER outshone TARGET fatally", "OWNER exposed TARGET to lethal radiation", "OWNER annihilated TARGET with raw energy" ]))); + else if(type == DAMAGE_TYPE_SLASHING) reason = llList2String([ "OWNER cut TARGET down", "OWNER carved TARGET apart", "OWNER opened TARGET up", "OWNER's blade met TARGET. Once was enough", "OWNER sliced through TARGET", "OWNER left TARGET with fatal cuts", "OWNER shredded TARGET", "OWNER tore TARGET apart", "OWNER made short work of TARGET", "OWNER showed TARGET the sharp end" ], (integer)llFrand((float)llGetListLength([ "OWNER cut TARGET down", "OWNER carved TARGET apart", "OWNER opened TARGET up", "OWNER's blade met TARGET. Once was enough", "OWNER sliced through TARGET", "OWNER left TARGET with fatal cuts", "OWNER shredded TARGET", "OWNER tore TARGET apart", "OWNER made short work of TARGET", "OWNER showed TARGET the sharp end" ]))); + else if(type == DAMAGE_TYPE_SONIC) reason = llList2String([ "OWNER blasted TARGET with sonic force", "OWNER collapsed TARGET's senses", "OWNER overwhelmed TARGET with pressure waves", "OWNER's sonic blast proved fatal for TARGET", "OWNER deafened TARGET permanently", "OWNER rocked TARGET with acoustic weaponry", "OWNER weaponized sound against TARGET", "OWNER won the volume war against TARGET", "OWNER shattered TARGET with sonic energy", "OWNER concussed TARGET with a sonic assault" ], (integer)llFrand((float)llGetListLength([ "OWNER blasted TARGET with sonic force", "OWNER collapsed TARGET's senses", "OWNER overwhelmed TARGET with pressure waves", "OWNER's sonic blast proved fatal for TARGET", "OWNER deafened TARGET permanently", "OWNER rocked TARGET with acoustic weaponry", "OWNER weaponized sound against TARGET", "OWNER won the volume war against TARGET", "OWNER shattered TARGET with sonic energy", "OWNER concussed TARGET with a sonic assault" ]))); + else if(type == DAMAGE_TYPE_EMOTIONAL) reason = llList2String([ "OWNER emotionally devastated TARGET", "OWNER collapsed TARGET's morale entirely", "OWNER broke TARGET's spirit", "OWNER dealt critical emotional damage to TARGET", "OWNER crushed TARGET's confidence", "OWNER won the psychological war against TARGET", "OWNER destroyed TARGET's morale", "OWNER applied pressure TARGET never recovered from", "OWNER left TARGET mentally defeated", "OWNER made sure TARGET took the loss personally" ], (integer)llFrand((float)llGetListLength([ "OWNER emotionally devastated TARGET", "OWNER collapsed TARGET's morale entirely", "OWNER broke TARGET's spirit", "OWNER dealt critical emotional damage to TARGET", "OWNER crushed TARGET's confidence", "OWNER won the psychological war against TARGET", "OWNER destroyed TARGET's morale", "OWNER applied pressure TARGET never recovered from", "OWNER left TARGET mentally defeated", "OWNER made sure TARGET took the loss personally" ]))); + else if(type == 100) reason = llList2String([ "OWNER's treatment killed TARGET", "OWNER's medical intervention proved fatal to TARGET", "OWNER flatlined TARGET during treatment", "OWNER gave TARGET one treatment too many", "OWNER's battlefield medicine failed TARGET catastrophically", "OWNER's bedside manner killed TARGET", "OWNER healed TARGET into the afterlife", "OWNER's medkit betrayed TARGET's trust", "OWNER's treatment proved terminal for TARGET", "OWNER medically neutralized TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER's treatment killed TARGET", "OWNER's medical intervention proved fatal to TARGET", "OWNER flatlined TARGET during treatment", "OWNER gave TARGET one treatment too many", "OWNER's battlefield medicine failed TARGET catastrophically", "OWNER's bedside manner killed TARGET", "OWNER healed TARGET into the afterlife", "OWNER's medkit betrayed TARGET's trust", "OWNER's treatment proved terminal for TARGET", "OWNER medically neutralized TARGET" ]))); + else if(type == 101) reason = llList2String([ "OWNER repaired TARGET into catastrophic failure", "OWNER's maintenance proved unsurvivable for TARGET", "OWNER's field repairs ended TARGET", "OWNER performed rapid unscheduled disassembly on TARGET", "OWNER pushed TARGET beyond operational limits", "OWNER's repair attempt finished TARGET off", "OWNER turned maintenance into a kill", "OWNER's adjustments collapsed TARGET's systems", "OWNER overclocked TARGET to destruction", "OWNER rendered TARGET permanently nonfunctional" ], (integer)llFrand((float)llGetListLength([ "OWNER repaired TARGET into catastrophic failure", "OWNER's maintenance proved unsurvivable for TARGET", "OWNER's field repairs ended TARGET", "OWNER performed rapid unscheduled disassembly on TARGET", "OWNER pushed TARGET beyond operational limits", "OWNER's repair attempt finished TARGET off", "OWNER turned maintenance into a kill", "OWNER's adjustments collapsed TARGET's systems", "OWNER overclocked TARGET to destruction", "OWNER rendered TARGET permanently nonfunctional" ]))); + else if(type == 102) reason = llList2String([ "OWNER blew TARGET apart", "OWNER caught TARGET in the blast radius", "OWNER introduced TARGET to high explosives", "OWNER fed TARGET a grenade", "OWNER detonated TARGET", "OWNER's explosives outran TARGET", "OWNER erased TARGET with a blast", "OWNER's detonation shredded TARGET", "OWNER reduced TARGET to debris", "OWNER's ordnance found TARGET standing too close" ], (integer)llFrand((float)llGetListLength([ "OWNER blew TARGET apart", "OWNER caught TARGET in the blast radius", "OWNER introduced TARGET to high explosives", "OWNER fed TARGET a grenade", "OWNER detonated TARGET", "OWNER's explosives outran TARGET", "OWNER erased TARGET with a blast", "OWNER's detonation shredded TARGET", "OWNER reduced TARGET to debris", "OWNER's ordnance found TARGET standing too close" ]))); + else if(type == 103) reason = llList2String([ "OWNER crushed TARGET", "OWNER flattened TARGET completely", "OWNER compressed TARGET into failure", "OWNER pinned and crushed TARGET", "OWNER turned pressure into a weapon against TARGET", "OWNER folded TARGET like field equipment", "OWNER reduced TARGET to scrap", "OWNER compacted TARGET", "OWNER buried TARGET under overwhelming pressure", "OWNER ran a compression test on TARGET. TARGET failed" ], (integer)llFrand((float)llGetListLength([ "OWNER crushed TARGET", "OWNER flattened TARGET completely", "OWNER compressed TARGET into failure", "OWNER pinned and crushed TARGET", "OWNER turned pressure into a weapon against TARGET", "OWNER folded TARGET like field equipment", "OWNER reduced TARGET to scrap", "OWNER compacted TARGET", "OWNER buried TARGET under overwhelming pressure", "OWNER ran a compression test on TARGET. TARGET failed" ]))); + else if(type == 104) reason = llList2String([ "OWNER punched through TARGET's armor", "OWNER penetrated TARGET with anti-armor fire", "OWNER defeated TARGET with AP rounds", "OWNER proved TARGET's armor was a suggestion", "OWNER cracked TARGET open with anti-vehicle fire", "OWNER's penetrators went straight through TARGET", "OWNER turned TARGET's armor into a liability", "OWNER gutted TARGET with heavy ordnance", "OWNER landed a clean anti-armor kill on TARGET", "OWNER won the armor check against TARGET" ], (integer)llFrand((float)llGetListLength([ "OWNER punched through TARGET's armor", "OWNER penetrated TARGET with anti-armor fire", "OWNER defeated TARGET with AP rounds", "OWNER proved TARGET's armor was a suggestion", "OWNER cracked TARGET open with anti-vehicle fire", "OWNER's penetrators went straight through TARGET", "OWNER turned TARGET's armor into a liability", "OWNER gutted TARGET with heavy ordnance", "OWNER landed a clean anti-armor kill on TARGET", "OWNER won the armor check against TARGET" ]))); + else if(type == -100) reason = llList2String([ "OWNER confirmed TARGET as 'probably hostile'", "OWNER eliminated TARGET with outstanding target identification skills", "OWNER won the team's internal conflict against TARGET", "OWNER revealed to TARGET that the IFF system was decorative", "OWNER engaged the nearest available lifeform: TARGET", "OWNER delivered allied fire to TARGET. Emphasis on allied", "OWNER mistook TARGET for a tactical opportunity", "OWNER killed TARGET with an aggressive interpretation of teamwork", "OWNER secured a friendly kill on TARGET. Mission unclear", "OWNER taught TARGET not to stand between a squadmate and bad decisions", "OWNER demonstrated why positive identification matters. TARGET objected briefly", "OWNER greeted TARGET with extremely friendly burst fire", "OWNER's suppressive fire caught TARGET non-suppressed", "OWNER showed TARGET a safety selector permanently set to optimism", "OWNER found TARGET faster than the enemy did", "OWNER introduced TARGET to friendly fire", "OWNER secured an enemy-adjacent kill on TARGET", "OWNER promoted TARGET to hostile", "OWNER carried the enemy team by killing TARGET", "OWNER won an internal faction dispute against TARGET", "OWNER treated TARGET like hostile armor", "OWNER killed TARGET with unwavering confidence and limited accuracy", "OWNER's line of fire met TARGET head-on", "OWNER killed TARGET after TARGET challenged basic firearm safety", "OWNER defended their muzzle space from TARGET", "OWNER shot TARGET, who appeared suddenly and unhelpfully", "OWNER's burst fire awarded TARGET a prize for crossing it", "OWNER eliminated TARGET, who selected poor timing as a lifestyle", "OWNER hit TARGET with rounds clearly intended for somebody else", "OWNER's bullets met TARGET halfway", "OWNER solved a tactical problem that TARGET inserted themselves into", "OWNER killed TARGET after TARGET performed unauthorized bullet interception", "OWNER was already shooting exactly where TARGET peeked", "OWNER dropped TARGET, who entered the doorway at maximum inconvenience", "OWNER engaged the enemy while TARGET was hugging it", "OWNER's suppressive fire was suppressed by TARGET's positioning", "OWNER demonstrated why you don't cross the firing lane. TARGET volunteered", "OWNER secured a friendly kill after TARGET sprinted through active gunfire", "OWNER penalized TARGET for performing close-quarters teamwork incorrectly", "OWNER shot TARGET, who materialized directly in the optic picture", "OWNER failed a trigger discipline test administered by TARGET at point-blank range", "OWNER accidentally fulfilled TARGET's request for covering fire", "OWNER charged TARGET full price for using the firing lane as a shortcut", "OWNER killed TARGET, who confused suppressive fire with navigational guidance", "OWNER confirmed TARGET located the line of fire by standing in it", "OWNER eliminated TARGET after TARGET ignored several visible warning signs", "OWNER intervened in TARGET's attempt at advanced teamwork without situational awareness", "OWNER shot TARGET, who committed aggressively to being in the way", "OWNER's ballistic calculations gained an unexpected variable: TARGET", "OWNER's aim was fine. TARGET's pathfinding was not", "OWNER cleared TARGET out from between themselves and success", "OWNER killed TARGET after TARGET made themselves tactically unavoidable" ], (integer)llFrand((float)llGetListLength([ "OWNER confirmed TARGET as 'probably hostile'", "OWNER eliminated TARGET with outstanding target identification skills", "OWNER won the team's internal conflict against TARGET", "OWNER revealed to TARGET that the IFF system was decorative", "OWNER engaged the nearest available lifeform: TARGET", "OWNER delivered allied fire to TARGET. Emphasis on allied", "OWNER mistook TARGET for a tactical opportunity", "OWNER killed TARGET with an aggressive interpretation of teamwork", "OWNER secured a friendly kill on TARGET. Mission unclear", "OWNER taught TARGET not to stand between a squadmate and bad decisions", "OWNER demonstrated why positive identification matters. TARGET objected briefly", "OWNER greeted TARGET with extremely friendly burst fire", "OWNER's suppressive fire caught TARGET non-suppressed", "OWNER showed TARGET a safety selector permanently set to optimism", "OWNER found TARGET faster than the enemy did", "OWNER introduced TARGET to friendly fire", "OWNER secured an enemy-adjacent kill on TARGET", "OWNER promoted TARGET to hostile", "OWNER carried the enemy team by killing TARGET", "OWNER won an internal faction dispute against TARGET", "OWNER treated TARGET like hostile armor", "OWNER killed TARGET with unwavering confidence and limited accuracy", "OWNER's line of fire met TARGET head-on", "OWNER killed TARGET after TARGET challenged basic firearm safety", "OWNER defended their muzzle space from TARGET", "OWNER shot TARGET, who appeared suddenly and unhelpfully", "OWNER's burst fire awarded TARGET a prize for crossing it", "OWNER eliminated TARGET, who selected poor timing as a lifestyle", "OWNER hit TARGET with rounds clearly intended for somebody else", "OWNER's bullets met TARGET halfway", "OWNER solved a tactical problem that TARGET inserted themselves into", "OWNER killed TARGET after TARGET performed unauthorized bullet interception", "OWNER was already shooting exactly where TARGET peeked", "OWNER dropped TARGET, who entered the doorway at maximum inconvenience", "OWNER engaged the enemy while TARGET was hugging it", "OWNER's suppressive fire was suppressed by TARGET's positioning", "OWNER demonstrated why you don't cross the firing lane. TARGET volunteered", "OWNER secured a friendly kill after TARGET sprinted through active gunfire", "OWNER penalized TARGET for performing close-quarters teamwork incorrectly", "OWNER shot TARGET, who materialized directly in the optic picture", "OWNER failed a trigger discipline test administered by TARGET at point-blank range", "OWNER accidentally fulfilled TARGET's request for covering fire", "OWNER charged TARGET full price for using the firing lane as a shortcut", "OWNER killed TARGET, who confused suppressive fire with navigational guidance", "OWNER confirmed TARGET located the line of fire by standing in it", "OWNER eliminated TARGET after TARGET ignored several visible warning signs", "OWNER intervened in TARGET's attempt at advanced teamwork without situational awareness", "OWNER shot TARGET, who committed aggressively to being in the way", "OWNER's ballistic calculations gained an unexpected variable: TARGET", "OWNER's aim was fine. TARGET's pathfinding was not", "OWNER cleared TARGET out from between themselves and success", "OWNER killed TARGET after TARGET made themselves tactically unavoidable" ]))); + else if(type == 105) reason = llList2String([ "OWNER deprived TARGET of oxygen", "OWNER suffocated TARGET", "OWNER left TARGET without breathable air", "OWNER ran TARGET out of air", "OWNER choked the life out of TARGET", "OWNER removed breathing from TARGET's options", "OWNER turned atmosphere into a privilege TARGET didn't have", "OWNER revoked TARGET's oxygen access", "OWNER asphyxiated TARGET", "OWNER reminded TARGET that breathing was mandatory. Too late" ], (integer)llFrand((float)llGetListLength([ "OWNER deprived TARGET of oxygen", "OWNER suffocated TARGET", "OWNER left TARGET without breathable air", "OWNER ran TARGET out of air", "OWNER choked the life out of TARGET", "OWNER removed breathing from TARGET's options", "OWNER turned atmosphere into a privilege TARGET didn't have", "OWNER revoked TARGET's oxygen access", "OWNER asphyxiated TARGET", "OWNER reminded TARGET that breathing was mandatory. Too late" ]))); + else reason = "OWNER killed TARGET with " + DamageTypeAsNoun(type) + " damage"; + } + + reason = llReplaceSubString(reason, "TARGET", "secondlife:///app/agent/" + (string)target + "/inspect", 0); + reason = llReplaceSubString(reason, "OWNER", "secondlife:///app/agent/" + (string)owner + "/inspect", 0); + return reason; +} + + +// Applies anti-armor damage in Combat2, with compatibility fallback to LBA (Listen Based Armor) +DamageArmor(key target, float damage) +{ + // Has Combat2 health, apply ANTI_ARMOR type + if(llGetHealth(target) > 0) + { + llDamage(target, damage, 104); + // llOwnerSay("Applied " + (string)damage + " anti-armor damage to " + (string)target); + } + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = ((integer)("0x" + llGetSubString(llMD5String(target, 0), 0, 3))); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + // llOwnerSay("Applied " + (string)damage + " LBA damage to " + (string)target); + } + } +} + +// Alternatively, if you have llDetectedType available, such as from a collision or a sensor, +// use that instead of llGetHealth as you can check for DAMAGEABLE which is more accurate +// as it signifies the object can actually process damage +DamageArmorDetected(integer index, float damage) +{ + key target = llDetectedKey(index); + integer detectedType = llDetectedType(index); + + // Can process damage, so apply ANTI_ARMOR type + if(detectedType & DAMAGEABLE) llDamage(target, damage, 104); + + else + { + // LBA damage fallback + string desc = (string)llGetObjectDetails(target, [OBJECT_DESC]); + if(llGetSubString(desc, 0, 5) == "LBA.v.") + { + integer channelLBA = ((integer)("0x" + llGetSubString(llMD5String(target, 0), 0, 3))); + llRegionSayTo(target, channelLBA, (string)target + "," + (string)damage); + } + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics.lsl new file mode 100644 index 0000000000..a386be24de --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's inverse-kinematics.lsl (include/NexiiLSL/LICENSE): negative +// numbers as macros, and what they are compared with. +#include "NexiiLSL/inverse-kinematics.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics_expected.lsl new file mode 100644 index 0000000000..3b903b5b2a --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_inverse_kinematics_expected.lsl @@ -0,0 +1,65 @@ +// NexiiLSL's inverse-kinematics.lsl (include/NexiiLSL/LICENSE): negative +// numbers as macros, and what they are compared with. + + + + + + +// Simple inverse kinematics via trigonemetry +TrigIK( + vector position, // relative position, only uses x (forward/back) and z (up/down) + float joint0, // First joint length + float joint1, // Second joint length + vector axis, // Axis that the joint rotates on + integer complementary // Whether to use complementary angle on result +) { + position.y = 0; + float distance = llVecMag(position); + + // If we can't reach it, just extend out + if(distance > joint0 + joint1) + { + IKr = -1; + IK1 = llEuler2Rot(-axis * llAtan2(position.z, position.x)); + IK2 = ZERO_ROTATION; + return; + } + + float distanceSq = distance * distance; + float joint0Sq = joint0 * joint0; + float joint1Sq = joint1 * joint1; + float x = (joint0Sq - joint1Sq + distanceSq) / (2 * joint0 * distance); + float y = (joint0Sq + joint1Sq - distanceSq) / (2 * joint0 * joint1); + float q0 = llAtan2(position.z, position.x); + + // Too close + if(x > 1 || x < -1 || y > 1 || y < -1) + { + IKr = -2; + IK1 = llEuler2Rot(-axis *( q0)); + IK2 = ZERO_ROTATION; + return; + } + + // There's two different ways to reach the same point + float q1; float q2; + if(complementary) + { + q1 = q0 - llAcos(x); + q2 = PI - llAcos(y); + } + else + { + q1 = q0 + llAcos(x); + q2 = PI + llAcos(y); + } + + IKr = 1; + IK1 = llEuler2Rot(-axis * q1); + IK2 = llEuler2Rot(-axis * q2); +} +// Return values: +integer IK; // IK results; IK_OUT_OF_REACH, IK_TOO_CLOSE, IK_SUCCESS +rotation IK1; // First joint rotation +rotation IK2; // Second joint rotation diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_linkset.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset.lsl new file mode 100644 index 0000000000..932d7bd274 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset.lsl @@ -0,0 +1,27 @@ +// NexiiLSL's linkset.lsl as the repository has it now (include/NexiiLSL/ +// LICENSE; include/linkset.lsl is an older copy), used as its comments +// show: statements for an argument, over several lines of their own. +#include "NexiiLSL/linkset.lsl" + +integer Foot; +integer Leg; + +default +{ + state_entry() + { + { + LinksetScan( + if(linkName == "Foot") Foot = link; + else if(linkName == "Leg") Leg = link; + ); + } + { + key object = llGetKey(); + ObjectLinksetScan(object, + if(linkName == "Turret") llOwnerSay("turret " + (string)linkKey); + else if(linkName == "Barrel") llOwnerSay("barrel " + (string)linkKey); + ); + } + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_linkset_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset_expected.lsl new file mode 100644 index 0000000000..ca948682c8 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_linkset_expected.lsl @@ -0,0 +1,220 @@ +// NexiiLSL's linkset.lsl as the repository has it now (include/NexiiLSL/ +// LICENSE; include/linkset.lsl is an older copy), used as its comments +// show: statements for an argument, over several lines of their own. + +//////////////////////////////////////////////////////////////////////////////////////////// +/// Linkset scanning functions +/// These functions return linkset numbers + +// Returns a list of linkset numbers from a prim name +list LinksByName(string needle) { + list needles; + integer link = 1; + integer prims = llGetNumberOfPrims(); + for(; link <= prims; ++link) if(llGetLinkName(link) == needle) needles += link; + return needles; +} + +// Returns a linkset number based on the name +integer LinkByName(string needle) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) if(llGetLinkName(prims) == needle) return prims; + return FALSE; +} + +// Converts a list of prim names to a list of linkset numbers +list LinksetList(list needles) { + integer prims = llGetNumberOfPrims() + 1; + while(--prims) { + integer pointer = llListFindList(needles, [llGetLinkName(prims)]); + if(~pointer) needles = llListReplaceList(needles, [prims], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate + + + + + + + +/* Usage: +LinksetScan( + if(linkName == "Foot") Foot = link; + else if(linkName == "Leg") Leg = link; + else if(linkName == "Torso") Torso = link; + else if(linkName == "Head") Head = link; +); +*/ + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// Remote Object Linkset scanning variants +/// These functions return keys instead of linkset numbers + +// Returns a list of linkset keys from a prim name +list ObjectLinksByName(key object, string needle) { + list needles; + integer link = 1; + integer prims = llGetObjectPrimCount(object); + for(; link <= prims; ++link) + { + key linkKey = llGetObjectLinkKey(object, link); + string linkName = llKey2Name(linkKey); + if(linkName == needle) needles += linkKey; + } + return needles; +} + +// Returns a linkset key based on the name +key ObjectLinkByName(key object, string needle) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) + { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + if(linkName == needle) return linkKey; + } + return NULL_KEY; +} + +// Converts a list of prim names to a list of linkset keys +list ObjectLinksetList(key object, list needles) { + integer prims = llGetObjectPrimCount(object) + 1; + while(--prims) { + key linkKey = llGetObjectLinkKey(object, prims); + string linkName = llKey2Name(linkKey); + integer pointer = llListFindList(needles, [linkName]); + if(~pointer) needles = llListReplaceList(needles, [linkKey], pointer, pointer); + } + return needles; +} + +// Roll your own loop boilerplate + + + + + + + + +/* Usage: +ObjectLinksetScan(object, + if(linkName == "Turret") ... + else if(linkName == "Barrel") ... +); +*/ + + +// Scans an object for a list of sitting avatars +list ObjectLinksetSittingAvatars(key object) { + list details = llGetObjectDetails(object, [OBJECT_PRIM_COUNT, OBJECT_SIT_COUNT]); + integer prims = llList2Integer(details, 0); + integer sitters = llList2Integer(details, 1); + + list agents; + integer index = prims + 1; + integer count = prims + sitters; + for(; index <= count; ++index) agents += llGetObjectLinkKey(object, index); + + return agents; +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////// +/// "Linkset Resources" use Linkset Data to store/cache an array of linkset numbers in a compact format +/// The usecase is typically for reusable prims/meshes in a linkset such as in a HUD or user interface +/// For example for dynamically rendering markers, text or for data visualisation + +// Sets up a Linkset Resource on the key kv, finding prims in the linkset whose name exactly matches pattern to put into the resource +LinksetResourceSetup(string kv, string pattern) { + string links; + integer iterator = llGetNumberOfPrims(); + while(iterator --> 0) + { + string name = llGetLinkName(1 + iterator); + if(name == pattern) links = llChar(iterator) + links; + } + llLinksetDataWrite(kv, links); +} + +// Pulls out a linkset number from the resource +integer LinksetResourceReserve(string kv) { + string links = llLinksetDataRead(kv); + if(links == "") return FALSE; + llLinksetDataWrite(kv, llDeleteSubString(links, 0, 0)); + return 1 + llOrd(links, 0); +} + +// Releases a linkset number back into the resource +LinksetResourceRelease(string kv, integer link) { + llLinksetDataWrite(kv, llChar(link - 1) + llLinksetDataRead(kv)); +} + +// Resets the prim params for a Linkset Resource, e.g. to reset a bunch of reusable prims to a default state out of sight +LinksetResourceReset(string kv, list reset) { + list params; + string links = llLinksetDataRead(kv); + integer iterator = llStringLength(links); + while(iterator --> 0) + { + params += [PRIM_LINK_TARGET, 1 + llOrd(links, iterator)] + reset; + if(llGetFreeMemory() < 1500) { llSetLinkPrimitiveParamsFast(0, params); params = []; } + } + if(params) llSetLinkPrimitiveParamsFast(0, params); +} + +integer LinksetResourceLookup(string kv, integer index) { + string links = llLinksetDataRead(kv); + if(links) return 1 + llOrd(links, index); + else return FALSE; +} + + + +// These two functions build upon linkset resources at a higher level, so that you can trade linkset numbers between resources +// For example to have one linkset resource of unused reusable prims, and another for reusable prims you are currently using to render in a UI + +// Takes out a linkset number from the "Pool" resource and puts it into the "Used" resource +// You can flip the kv's to do the reverse +integer LinksetResourceUse(string kvPool, string kvUsed) { + string links = llLinksetDataRead(kvPool); + if(links == "") return FALSE; + llLinksetDataWrite(kvPool, llDeleteSubString(links, 0, 0)); + integer link = 1 + llOrd(links, 0); + llLinksetDataWrite(kvUsed, llChar(link - 1) + llLinksetDataRead(kvUsed)); + return link; +} + +// Frees up all the linkset numbers by moving all from the "Used" resource and puts them back into "Pool" resource +LinksetResourceFreeAll(string kvPool, string kvUsed) { + llLinksetDataWrite(kvPool, llLinksetDataRead(kvUsed) + llLinksetDataRead(kvPool)); + llLinksetDataWrite(kvUsed, ""); +} + +// TODO: A way to move a specific linkset number from a "Used" resource into the "Pool" resource in one function +// LinksetResourceFree(string kvPool, string kvUsed, integer link) {...} + +integer Foot; +integer Leg; + +default +{ + state_entry() + { + { + integer link = llGetNumberOfPrims(); do { string linkName = llGetLinkName(link); if(linkName == "Foot") Foot = link; else if(linkName == "Leg") Leg = link; } while(--link > 1);; + } + { + key object = llGetKey(); + integer link = llGetObjectPrimCount(object); do { key linkKey = llGetObjectLinkKey(object, link); string linkName = llKey2Name(linkKey); if(linkName == "Turret") llOwnerSay("turret " + (string)linkKey); else if(linkName == "Barrel") llOwnerSay("barrel " + (string)linkKey); } while(--link > 1);; + } + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data.lsl new file mode 100644 index 0000000000..386a87525b --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data.lsl @@ -0,0 +1,6 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with what +// its #ifdefs ask for defined: an #ifdef indented inside a function, and +// inside a list over several lines. +#define RLSD_LINK_START 3 +#define RLSD_DISPLAY +#include "NexiiLSL/remote-linkset-data.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare.lsl new file mode 100644 index 0000000000..77d2ab5c19 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with +// nothing its #ifdefs ask for defined: each left out. +#include "NexiiLSL/remote-linkset-data.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare_expected.lsl new file mode 100644 index 0000000000..11ba47edbf --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_bare_expected.lsl @@ -0,0 +1,107 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with +// nothing its #ifdefs ask for defined: each left out. + +// Implementation of a public Remote Linkset Data storage system, similar in practice to llLinksetDataRead but on a remote object +// Gotcha is that this works entirely through prim params (names, descriptions and/or hover text to store data) +// This does mean that this it is mostly suitable for a server object to hold the remote linkset data + +// PRIM_NAME -- 63 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_DESC -- 127 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_TEXT -- 254 bytes, full UTF-8 range -- best for storing the raw values chunked across multiple prims +// -- Warning: Any PRIM_TEXT with unicode in range 0x1-0x1F via llChar corrupts the object from being rezzed from inventory +// Storage capacity is dependent on the number of prims in the linkset +// Values are divided by 254 bytes per child prim's PRIM_TEXT, but it is not guaranteed to utilise capacity fully +// as data is reserved in 254 byte chunks for each `name:value` pair, so e.g. if you have a linkset of 61 prims for example (where 1 prim is the root): +// - 60x`name:value` pairs if value is <= 254 bytes, or +// - 30x`name:value` pairs if values are up to 254*2 bytes. + +string RemoteLinksetDataRead(key object, string name) +{ + if(object == llGetKey() || object == NULL_KEY) return llLinksetDataRead("Remote." + name); + + // TODO: Lookup table on root prim? + integer link = 1; + @remoteReadSearch; + key prim = llGetObjectLinkKey(object, ++link); + if(prim == NULL_KEY) return ""; + string primName = llKey2Name(prim); + if(primName != name) jump remoteReadSearch; + + string value; + @remoteReadLoop; + value += (string)llGetObjectDetails(prim, [OBJECT_TEXT]); + prim = llGetObjectLinkKey(object, ++link); + primName = llKey2Name(prim); + if(primName == name) jump remoteReadLoop; + + return value; +} + +integer RemoteLinksetDataWrite(key object, string name, string value) +{ + if(object != llGetKey() && object != NULL_KEY) return 6; // Currently only allow writing from own object to keep the implementation straightforward + + // TODO: Check many prims we have left available for writing (any prims free at all + chunk/prim for full value) + + // We mirror the data locally for rebuilds + integer result = llLinksetDataWrite("Remote." + name, value); + if(result) return result; + + RebuildRemoteLinksetData(); + + return LINKSETDATA_OK; +} + + + +// INTERNAL -- Rebuild entire remote dataset, this is not efficient but it keeps the implementation robust, avoiding defragmentation issues +RebuildRemoteLinksetData() +{ + list params; + + list names = llLinksetDataFindKeys("^Remote\\.", 0, 0); + integer link = 2; + + + + integer index; + integer count = llGetListLength(names); + vector lastColor; + for(; index < count; ++index) + { + string name = llList2String(names, index); + string value = llLinksetDataRead(name); + name = llDeleteSubString(name, 0, 6); + + vector color = (vector)llLinksetDataRead("RemoteDebug." + name); + while(color == ZERO_VECTOR || color * lastColor > 0.8) llLinksetDataWrite("RemoteDebug." + name, ((string)(color = llVecNorm()))); + lastColor = color; + + integer chunks = llCeil(llStringLength(value) / 254.); + while(chunks --> 0) + { + string chunk = llGetSubString(value, 0, 253); + value = llDeleteSubString(value, 0, 253); + params += [ + PRIM_LINK_TARGET, link++, + PRIM_NAME, name, + PRIM_TEXT, chunk, <0, 0, 0>, 0 + + + + + ]; + } + } + + // TODO: Lookup table on root prim? + + llSetLinkPrimitiveParamsFast(LINK_ALL_CHILDREN, [ + PRIM_NAME, "-", + PRIM_TEXT, "", <0, 0, 0>, 0 + + + + + ] + params); +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_expected.lsl new file mode 100644 index 0000000000..9265051c1d --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_remote_linkset_data_expected.lsl @@ -0,0 +1,110 @@ +// NexiiLSL's remote-linkset-data.lsl (include/NexiiLSL/LICENSE) with what +// its #ifdefs ask for defined: an #ifdef indented inside a function, and +// inside a list over several lines. + + + +// Implementation of a public Remote Linkset Data storage system, similar in practice to llLinksetDataRead but on a remote object +// Gotcha is that this works entirely through prim params (names, descriptions and/or hover text to store data) +// This does mean that this it is mostly suitable for a server object to hold the remote linkset data + +// PRIM_NAME -- 63 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_DESC -- 127 bytes, restricted UTF-8 range, 32-126 except 124 ("|") +// PRIM_TEXT -- 254 bytes, full UTF-8 range -- best for storing the raw values chunked across multiple prims +// -- Warning: Any PRIM_TEXT with unicode in range 0x1-0x1F via llChar corrupts the object from being rezzed from inventory +// Storage capacity is dependent on the number of prims in the linkset +// Values are divided by 254 bytes per child prim's PRIM_TEXT, but it is not guaranteed to utilise capacity fully +// as data is reserved in 254 byte chunks for each `name:value` pair, so e.g. if you have a linkset of 61 prims for example (where 1 prim is the root): +// - 60x`name:value` pairs if value is <= 254 bytes, or +// - 30x`name:value` pairs if values are up to 254*2 bytes. + +string RemoteLinksetDataRead(key object, string name) +{ + if(object == llGetKey() || object == NULL_KEY) return llLinksetDataRead("Remote." + name); + + // TODO: Lookup table on root prim? + integer link = 1; + @remoteReadSearch; + key prim = llGetObjectLinkKey(object, ++link); + if(prim == NULL_KEY) return ""; + string primName = llKey2Name(prim); + if(primName != name) jump remoteReadSearch; + + string value; + @remoteReadLoop; + value += (string)llGetObjectDetails(prim, [OBJECT_TEXT]); + prim = llGetObjectLinkKey(object, ++link); + primName = llKey2Name(prim); + if(primName == name) jump remoteReadLoop; + + return value; +} + +integer RemoteLinksetDataWrite(key object, string name, string value) +{ + if(object != llGetKey() && object != NULL_KEY) return 6; // Currently only allow writing from own object to keep the implementation straightforward + + // TODO: Check many prims we have left available for writing (any prims free at all + chunk/prim for full value) + + // We mirror the data locally for rebuilds + integer result = llLinksetDataWrite("Remote." + name, value); + if(result) return result; + + RebuildRemoteLinksetData(); + + return LINKSETDATA_OK; +} + + + +// INTERNAL -- Rebuild entire remote dataset, this is not efficient but it keeps the implementation robust, avoiding defragmentation issues +RebuildRemoteLinksetData() +{ + list params; + + list names = llLinksetDataFindKeys("^Remote\\.", 0, 0); + integer link = 2; + + link = 3; + + integer index; + integer count = llGetListLength(names); + vector lastColor; + for(; index < count; ++index) + { + string name = llList2String(names, index); + string value = llLinksetDataRead(name); + name = llDeleteSubString(name, 0, 6); + + vector color = (vector)llLinksetDataRead("RemoteDebug." + name); + while(color == ZERO_VECTOR || color * lastColor > 0.8) llLinksetDataWrite("RemoteDebug." + name, ((string)(color = llVecNorm()))); + lastColor = color; + + integer chunks = llCeil(llStringLength(value) / 254.); + while(chunks --> 0) + { + string chunk = llGetSubString(value, 0, 253); + value = llDeleteSubString(value, 0, 253); + params += [ + PRIM_LINK_TARGET, link++, + PRIM_NAME, name, + PRIM_TEXT, chunk, <0, 0, 0>, 0 + + + , PRIM_COLOR, 0, color, 1 + + ]; + } + } + + // TODO: Lookup table on root prim? + + llSetLinkPrimitiveParamsFast(LINK_ALL_CHILDREN, [ + PRIM_NAME, "-", + PRIM_TEXT, "", <0, 0, 0>, 0 + + + , PRIM_COLOR, 0, <0, 0, 0>, 1 + + ] + params); +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check.lsl new file mode 100644 index 0000000000..cee21ddef3 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's scripts/rez-queue-check.lsl (include/NexiiLSL/LICENSE): a +// whole script, which includes app-uri.lsl by the repository's path. +#include "NexiiLSL/scripts/rez-queue-check.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check_expected.lsl new file mode 100644 index 0000000000..6f4da18dc8 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_rez_queue_check_expected.lsl @@ -0,0 +1,264 @@ +// NexiiLSL's scripts/rez-queue-check.lsl (include/NexiiLSL/LICENSE): a +// whole script, which includes app-uri.lsl by the repository's path. + + + + + + + + + + + + + +float start; +float end; +integer hasQueue; +integer hasLargeQueue; +list tracked; + +integer lastCount; +integer tick; + +checkAttachments() +{ + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + string animation = llGetAnimation(agent); + string legacyName = llKey2Name(agent); // Ghosted avatars have an empty string + string displayName = llGetDisplayName(agent); // May not always be non-empty string? + string userName = llGetUsername(agent); // May not always be non-empty string? + list attachments = llGetAttachedList(agent); + + if(animation == "" + || animation == "Init" + || legacyName == "" + || displayName == "" + || userName == "" + || llGetListLength(attachments) == 0) + jump continue; // Do not consider agent if entering/exiting region, logging, teleporting or ghosted + + list prevAttachments = llJson2List(llLinksetDataRead(agent + "_attachments")); + list attached; + list detached; + + // Attached + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + integer pointer = llListFindList(prevAttachments, [attachment]); + if(pointer == -1) + { + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + attached += attachment; + } + } + + // Detached + index = llGetListLength(prevAttachments); + while(index --> 0) + { + string attachment = llList2String(prevAttachments, index); + integer pointer = llListFindList(attachments, [(key)attachment]); + if(pointer == -1) + { + // llLinksetDataDelete(agent + "_" + attachment); + detached += attachment; + } + } + + // Cache + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + + + //if(hasQueue || hasLargeQueue) + //{ + //string message; + integer a = llGetListLength(attached); + integer d = llGetListLength(detached); + /*if(a && d) message = agentAboutURI(agent) + " attached " + string(a) + " and detached " + string(d) + " items"; + else if(a) message = agentAboutURI(agent) + " attached " + string(a) + " items"; + else if(d) message = agentAboutURI(agent) + " detached " + string(d) + " items";*/ + + if(a) tracked += "secondlife:///app/agent/" + ((string)(agent)) + "/about"; + + /*while(a --> 0) + { + string attachment = llList2String(attached, a); + message += "\n+ " + objectURI(attachment); + }*/ + + while(d --> 0) + { + string attachment = llList2String(detached, d); + // message += "\n- " + llLinksetDataRead(attachment + "_name"); + llLinksetDataDelete(attachment + "_name"); + // llLinksetDataDelete(agent + "_" + attachment); + } + + //if(message) llOwnerSay(message); + //} + + @continue; + } +} + +announce(string message) { + string name = llGetObjectName(); + llSetObjectName(llGetRegionName()); + + //llSay(PUBLIC_CHANNEL, message); + + //* + list agents = llJson2List(llLinksetDataRead("agents")); + integer iterator = llGetListLength(agents); + while(iterator --> 0) + { + key agent = llList2Key(agents, iterator); + llRegionSayTo(agent, PUBLIC_CHANNEL, message); + } + //*/ + + llSetObjectName(name); +} + + +default +{ + state_entry() + { + llSetText("", <1,1,1>, 1); + llLinksetDataDeleteFound("_(attachments|name)$", ""); + + list agents = llGetAgentList(AGENT_LIST_REGION, []); + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + integer iterator = lastCount = llGetListLength(agents); + while(iterator --> 0) + { + string agent = llList2String(agents, iterator); + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + integer index = llGetListLength(attachments); + while(index --> 0) + { + string attachment = llList2String(attachments, index); + llLinksetDataWrite(attachment + "_name", llKey2Name(attachment)); + } + } + + llOwnerSay("Initialise Rez Queue Checker (Mem " + llGetSubString((string)(llGetFreeMemory() / 1024.0), 0, -6) + "KB / LSD " + llGetSubString((string)(llLinksetDataAvailable() / 1024.0), 0, -6) + "KB)"); + // llOwnerSay((string)); + + llSetTimerEvent(20/45.); + checkAttachments(); + } + + timer() + { + if(llGetTime() > 60*60*12) llResetScript(); + + ++tick; + + integer count = llGetRegionAgentCount(); + if(lastCount != count) + { + lastCount = count; + list lastAgents = llJson2List(llLinksetDataRead("agents")); + list agents = llGetAgentList(AGENT_LIST_REGION, []); + list added; + list removed; + while(count --> 0) + { + string agent = llList2String(agents, count); + integer pointer = llListFindList(lastAgents, [agent]); + if(pointer == -1) + { + added += "secondlife:///app/agent/" + ((string)(agent)) + "/about"; + list attachments = llGetAttachedList(agent); + llLinksetDataWrite(agent + "_attachments", llList2Json(JSON_ARRAY, attachments)); + } + } + + count = llGetListLength(lastAgents); + while(count --> 0) + { + string agent = llList2String(lastAgents, count); + integer pointer = llListFindList(agents, [(key)agent]); + if(pointer == -1) + { + removed += "secondlife:///app/agent/" + ((string)(agent)) + "/about"; + llLinksetDataDeleteFound("^" + agent, ""); + } + } + + //if(removed) llOwnerSay("Removed: " + llList2CSV(removed)); + //if(added) llOwnerSay("Added: " + llList2CSV(added)); + + llLinksetDataWrite("agents", llList2Json(JSON_ARRAY, agents)); + } + + float now = llGetTime(); + float downloads = llGetSimStats(SIM_STAT_ASSET_DOWNLOADS); + float uploads = llGetSimStats(SIM_STAT_ASSET_UPLOADS); + + // if(downloads > 0.5 || uploads > 0.5) + if(downloads > 0.01 || uploads > 0.01) + { + if(!hasQueue) + { + //llOwnerSay("Downloads pending!"); + hasQueue = TRUE; + end = start = now; + } + + else if(hasQueue) + { + end = now; + if(downloads > 0.9 && /*now - end > 1.5 &&*/ !hasLargeQueue) + { + hasLargeQueue = TRUE; + tracked = []; + announce("Rez queue detected!"); + } + } + } + + else if(hasLargeQueue) + { + float delta = end - start; + start = end = 0; + checkAttachments(); + string message = "Rez queue ended after " + llGetSubString((string)delta, 0, -5) + "s"; + if(tracked) message += " due to " + llDumpList2String(tracked, ", "); + announce(message); + hasQueue = hasLargeQueue = FALSE; + tracked = []; + } + + else if(hasQueue) + { + float delta = end - start; + checkAttachments(); + //llOwnerSay("Pending over after " + llGetSubString((string)delta, 0, -5) + "s"); + hasQueue = FALSE; + } + + else if(!(tick % 45)) checkAttachments(); + } + + changed(integer change) + { + if(change & CHANGED_REGION) llResetScript(); + } + + attach(key avatar) + { + llResetScript(); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout.lsl new file mode 100644 index 0000000000..989b330c49 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout.lsl @@ -0,0 +1,3 @@ +// NexiiLSL's scripts/roundabout.lsl (include/NexiiLSL/LICENSE): a whole +// script, with strings over several lines, which includes time.lsl. +#include "NexiiLSL/scripts/roundabout.lsl" diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout_expected.lsl new file mode 100644 index 0000000000..a370f03829 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_roundabout_expected.lsl @@ -0,0 +1,354 @@ +// NexiiLSL's scripts/roundabout.lsl (include/NexiiLSL/LICENSE): a whole +// script, with strings over several lines, which includes time.lsl. + +/* +The Magic Roundabout + +This script is designed to help with the dilemma of assigning one script in the region +as the main controller responsible for loading remote data such as from experience storage +or HTTP requests and then having to distribute any loaded data to other subordinate scripts + +Make sure the script is set to no modify so people can't peek at the private key used +-- that is the only piece of info that must truly be kept secret + +Because the script sends along a signature in the listener messages, it means you are +limited to 852 bytes max (1024 - 172) +*/ + + +/* + Compares two timestamps per ISO 8601 format "YYYY-MM-DDThh:mm:ss.ff..fZ" + Returns -1 if a < b, else 1 if a > b else 0 if same + + You can comment/cut out parts of the function if you only care about + date checks or only want fast time checks +*/ +integer CompareTimestamps(string a, string b) +{ + integer aYear = (integer)llGetSubString(a, 0, 3); + integer bYear = (integer)llGetSubString(b, 0, 3); + if(aYear < bYear) return -1; else if(aYear > bYear) return 1; + integer aMonth = (integer)llGetSubString(a, 5, 6); + integer bMonth = (integer)llGetSubString(b, 5, 6); + if(aMonth < bMonth) return -1; else if(aMonth > bMonth) return 1; + integer aDay = (integer)llGetSubString(a, 8, 9); + integer bDay = (integer)llGetSubString(b, 8, 9); + if(aDay < bDay) return -1; else if(aDay > bDay) return 1; + integer aHour = (integer)llGetSubString(a, 11, 12); + integer bHour = (integer)llGetSubString(b, 11, 12); + if(aHour < bHour) return -1; else if(aHour > bHour) return 1; + integer aMinute = (integer)llGetSubString(a, 14, 15); + integer bMinute = (integer)llGetSubString(b, 14, 15); + if(aMinute < bMinute) return -1; else if(aMinute > bMinute) return 1; + float aSecond = (float)llGetSubString(a, 17, -2); + float bSecond = (float)llGetSubString(b, 17, -2); + if(aSecond < bSecond) return -1; else if(aSecond > bSecond) return 1; + return 0; +} + +// Pure string version of https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Unix(string stamp) +{ + integer year = (integer)llGetSubString(stamp, 0, 3); + integer month = (integer)llGetSubString(stamp, 5, 6); + integer day = (integer)llGetSubString(stamp, 8, 9); + integer hours = (integer)llGetSubString(stamp, 11, 12); + integer minutes = (integer)llGetSubString(stamp, 14, 15); + integer seconds = (integer)llGetSubString(stamp, 17, -2); + + year -= 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + + month = ~-month; + day = ~-day; + + integer days = (integer)(year * 365.25 + 0.25) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + day; + + return ( + days * 86400 + + hours * 3600 + + minutes * 60 + + seconds + ); +} + +// Compact Check +integer TimestampIsBefore(string a, string b) +{ + if((integer)llGetSubString(a, 8, 9) < (integer)llGetSubString(b, 8, 9)) return TRUE; + if((integer)llGetSubString(a, 11, 12) < (integer)llGetSubString(b, 11, 12)) return TRUE; + if((integer)llGetSubString(a, 14, 15) < (integer)llGetSubString(b, 14, 15)) return TRUE; + if((float)llGetSubString(a, 17, -2) < (float)llGetSubString(b, 17, -2)) return TRUE; + return FALSE; +} + +integer TimestampCompare(string a, string b) +{ + integer aDate = (integer)(llGetSubString(a, 0, 3) + llGetSubString(a, 5, 6) + llGetSubString(a, 8, 9)); + integer bDate = (integer)(llGetSubString(b, 0, 3) + llGetSubString(b, 5, 6) + llGetSubString(b, 8, 9)); + if(aDate < bDate) return -1; + else if(aDate > bDate) return 1; + float aTime = (float)(llGetSubString(a, 11, 12) + llGetSubString(a, 14, 15) + llGetSubString(a, 17, -1)); + float bTime = (float)(llGetSubString(b, 11, 12) + llGetSubString(b, 14, 15) + llGetSubString(b, 17, -1)); + llOwnerSay("aTime: " + (string)aTime + "; bTime: " + (string)bTime); + if(aTime < bTime) return -1; + else if(aTime > bTime) return 1; + return 0; +} + +// Milliseconds since beginning of month for timestamp +integer Timestamp2Millisec(string stamp) +{ + return (integer)llGetSubString(stamp, 8, 9) * 86400000 + // Days + (integer)llGetSubString(stamp, 11, 12) * 3600000 + // Hours + (integer)llGetSubString(stamp, 14, 15) * 60000 + // Minutes + llRound(((float)llGetSubString(stamp, 17, -2) * 1000.0)) // Seconds.Milliseconds + - 617316353; // Offset to fit between [-617316353,2147483547] +} + +// Seconds since timestamp epoch, per https://wiki.secondlife.com/wiki/Stamp2UnixInt +integer Timestamp2Seconds(string stamp) +{ + list parts = llParseString2List(stamp, ["-", "T", ":", "."], []); + integer year = llList2Integer(parts, 0) - 1902; + if(year >> 31 | year / 136) return 2145916800 * (1 | year >> 31); + integer month = ~-llList2Integer(parts, 1); + return 86400 * (((integer)(year * 365.25 + 0.25)) - 24837 + + month * 30 + (month - (month < 7) >> 1) + (month < 2) - + (((year + 2) & 3) > 0) * (month > 1) + + (~-llList2Integer(parts, 2))) + + llList2Integer(parts, 3) * 3600 + + llList2Integer(parts, 4) * 60 + + llList2Integer(parts, 5); +} + + + +string PrivateKey = "-----BEGIN RSA PRIVATE KEY-----\nreplace with private key\n-----END RSA PRIVATE KEY-----" + +; +string PublicKey = "-----BEGIN PUBLIC KEY-----\nreplace with public key\n-----END PUBLIC KEY-----" + +; +float LastPingPong = -60.0; +float LastController = -60.0; + +key Controller = NULL_KEY; +list Subordinates; + +integer Tick; + +default +{ + state_entry() + { + llMessageLinked(LINK_SET, -100, "init", ""); + llLinksetDataWrite("Roundabout", "init"); + state subordinate; + } +} + +state subordinate +{ + state_entry() + { + llListen(-100, "", "", ""); + llSetTimerEvent(10.0); + + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(-100, signature + payload); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + LastPingPong = llGetTime(); + + // Respond to ping with pong + text = "pong," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + } + + else if(message == "pong") + { + LastPingPong = llGetTime(); + + // Was controller, but might have been reset + if(identifier == Controller) Controller = NULL_KEY; + + // Add to subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer == -1) Subordinates += identifier; + } + + else if(message == "controller") + { + LastController = llGetTime(); + + // We just initialised and there is a controller out there + // Announce to other scripts we are subordinate and ready to request data + if(Controller == NULL_KEY) + { + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, -100, "subordinate", ""); + Controller = identifier; + } + + // New controller + else if(identifier != Controller) Controller = identifier; + + // Same controller + else if(identifier == Controller); + + // Remove from subordinates list + integer pointer = llListFindList(Subordinates, [identifier]); + if(pointer != -1) Subordinates = llDeleteSubList(Subordinates, pointer, pointer); + } + + // Pass along any other messages from controller into linkset + else if(identifier == Controller) + { + llMessageLinked(LINK_SET, -100, text, identifier); + } + } + + timer() + { + ++Tick; + float time = llGetTime(); + + // We lost our controller + if(time - LastController > 60.0 || (Controller != NULL_KEY && llKey2Name(Controller) == "")) + { + // Promote oldest subordinate to controller + key subordinate = llGetKey(); + list checks = [subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME]))]; + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) != "") checks += [ + subordinate, Timestamp2Unix((string)llGetObjectDetails(subordinate, [OBJECT_REZ_TIME])) + ]; + } + checks = llListSortStrided(checks, 2, 1, TRUE); + subordinate = llList2Key(checks, 0); + + // Promote ourselves + if(subordinate == llGetKey()) state controller; + + // Promote subodinate to controller + else Controller = subordinate; + } + + // Ping everyone + else if(time - LastPingPong > 30.0) + { + LastPingPong = llGetTime(); + string payload = "ping," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, payload, "sha512"); + llRegionSay(-100, signature + payload); + } + + // Garbage collection on list + if(!(Tick % 16)) + { + integer iterator = llGetListLength(Subordinates); + while(iterator --> 0) + { + key subordinate = llList2Key(Subordinates, iterator); + if(llKey2Name(subordinate) == "") + { + llOwnerSay("Lost subordinate " + (string)subordinate); + Subordinates = llDeleteSubList(Subordinates, iterator, iterator); + } + } + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == -100 + 1 && Controller != NULL_KEY) + { + // Pass along messages from subordinate to controller + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSayTo(Controller, -100, signature + text); + } + } + + state_exit() + { + // We don't track subordinates outside this state + Subordinates = []; + } +} + + +state controller +{ + state_entry() + { + string text = "controller," + (string)llGenerateKey(); + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + + llLinksetDataWrite("Roundabout", "controller"); + llMessageLinked(LINK_SET, -100, "controller", ""); + llListen(-100, "", "", ""); + } + + listen(integer channel, string name, key identifier, string text) + { + string signature = llGetSubString(text, 0, 171); + text = llDeleteSubString(text, 0, 171); + if(!llVerifyRSA(PublicKey, text, signature, "sha512")) return; + + string message = llGetSubString(text, 0, llSubStringIndex(text, ",") - 1); + + if(message == "ping") + { + // Respond to ping with controller + text = "controller," + (string)llGenerateKey(); + signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + } + else if(message == "pong"); // Ignore + else if(message == "controller") + { + // Huh? There was another controller? + LastController = llGetTime(); + Controller = identifier; + llLinksetDataWrite("Roundabout", "subordinate"); + llMessageLinked(LINK_SET, -100, "subordinate", ""); + state subordinate; + } + + // Pass along any other messages from subordinates into linkset + else + { + llMessageLinked(LINK_SET, -100, text, identifier); + } + } + + link_message(integer sender, integer channel, string text, key identifier) + { + if(channel == -100 + 1) + { + // Pass along messages from controller to subordinates + string signature = llSignRSA(PrivateKey, text, "sha512"); + llRegionSay(-100, signature + text); + } + } +} \ No newline at end of file diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression.lsl new file mode 100644 index 0000000000..c1d9a02a7f --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression.lsl @@ -0,0 +1,18 @@ +// NexiiLSL's library for packing strings small (include/NexiiLSL/ +// LICENSE), string-compression.lsl -- its name kept off the first line, +// which would ask the golden run for compression: an include of an +// include by the path the repository gives it, #defines +// inside blocks, a name used before the #define that makes it a macro +// (`RANGE`, left as it is, as C leaves it), and a cast with its line's +// brackets unbalanced around it. Its constants it does not use itself are +// used in a state after it. +#include "NexiiLSL/string-compression.lsl" + +state limits +{ + state_entry() + { + list limits = [MAXIMUM, UTF8_1B_MAX, UTF8_2B_MAX, UTF8_3B_MAX, UTF8_4B_MAX, + UTF8_SURROGATE_START, UTF8_SURROGATE_END, UTF8_NONCHARACTER_1, UTF8_NONCHARACTER_2]; + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression_expected.lsl new file mode 100644 index 0000000000..0e591490ff --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_string_compression_expected.lsl @@ -0,0 +1,272 @@ +// NexiiLSL's library for packing strings small (include/NexiiLSL/ +// LICENSE), string-compression.lsl -- its name kept off the first line, +// which would ask the golden run for compression: an include of an +// include by the path the repository gives it, #defines +// inside blocks, a name used before the #define that makes it a macro +// (`RANGE`, left as it is, as C leaves it), and a cast with its line's +// brackets unbalanced around it. Its constants it does not use itself are +// used in a state after it. + + + + + +// For displaying ordinal numbers like 1st, 2nd, 3rd, 20th, 21st, 22nd, etc. +string nth(integer value) +{ + value = value % 10; + if(value == 1) return "st"; + if(value == 2) return "nd"; + if(value == 3) return "rd"; + return "th"; +} + +// Returns the number of bytes a string would take up in UTF-8 encoding +integer stringBytes(string str) { + integer bytes; + integer length = llStringLength(str); + integer iterator; + for(; iterator < length; ++iterator) + { + integer byte = llOrd(str, iterator); + if(byte <= 0x7F) bytes += 1; + else if(byte <= 0x07FF) bytes += 2; + else if(byte <= 0xFFFF) bytes += 3; + else bytes += 4; + } + return bytes; +} + +// Significantly faster +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + + +// From https://wiki.secondlife.com/wiki/Efficient_Hex +string bits2nybbles(integer bits) { + integer lsn; // least significant nybble + string nybbles = ""; + do + nybbles = llGetSubString("0123456789abcdef", lsn = (bits & 0xF), lsn) + nybbles; + while (bits = (0xfffFFFF & (bits >> 4))); + return nybbles; +} + + +string AttachmentPointAsName(integer point) +{ + if(point == ATTACH_HEAD) return "Skull"; + if(point == ATTACH_NOSE) return "Nose"; + if(point == ATTACH_MOUTH) return "Mouth"; + if(point == ATTACH_FACE_TONGUE) return "Tongue"; + if(point == ATTACH_CHIN) return "Chin"; + if(point == ATTACH_FACE_JAW) return "Jaw"; + if(point == ATTACH_LEAR) return "Left Ear"; + if(point == ATTACH_REAR) return "Right Ear"; + if(point == ATTACH_FACE_LEAR) return "Alt Left Ear"; + if(point == ATTACH_FACE_REAR) return "Alt Right Ear"; + if(point == ATTACH_LEYE) return "Left Eye"; + if(point == ATTACH_REYE) return "Right Eye"; + if(point == ATTACH_FACE_LEYE) return "Alt Left Eye"; + if(point == ATTACH_FACE_REYE) return "Alt Right Eye"; + if(point == ATTACH_NECK) return "Neck"; + if(point == ATTACH_LSHOULDER) return "Left Shoulder"; + if(point == ATTACH_RSHOULDER) return "Right Shoulder"; + if(point == ATTACH_LUARM) return "L Upper Arm"; + if(point == ATTACH_RUARM) return "R Upper Arm"; + if(point == ATTACH_LLARM) return "L Lower Arm"; + if(point == ATTACH_RLARM) return "R Lower Arm"; + if(point == ATTACH_LHAND) return "Left Hand"; + if(point == ATTACH_RHAND) return "Right Hand"; + if(point == ATTACH_LHAND_RING1) return "Left Ring Finger"; + if(point == ATTACH_RHAND_RING1) return "Right Ring Finger"; + if(point == ATTACH_LWING) return "Left Wing"; + if(point == ATTACH_RWING) return "Right Wing"; + if(point == ATTACH_CHEST) return "Chest"; + if(point == ATTACH_LEFT_PEC) return "Left Pec"; + if(point == ATTACH_RIGHT_PEC) return "Right Pec"; + if(point == ATTACH_BELLY) return "Stomach"; + if(point == ATTACH_BACK) return "Spine"; + if(point == ATTACH_TAIL_BASE) return "Tail Base"; + if(point == ATTACH_TAIL_TIP) return "Tail Tip"; + if(point == ATTACH_AVATAR_CENTER) return "Avatar Center"; + if(point == ATTACH_PELVIS) return "Pelvis"; + if(point == ATTACH_GROIN) return "Groin"; + if(point == ATTACH_LHIP) return "Left Hip"; + if(point == ATTACH_RHIP) return "Right Hip"; + if(point == ATTACH_LULEG) return "L Upper Leg"; + if(point == ATTACH_RULEG) return "R Upper Leg"; + if(point == ATTACH_RLLEG) return "R Lower Leg"; + if(point == ATTACH_LLLEG) return "L Lower Leg"; + if(point == ATTACH_LFOOT) return "Left Foot"; + if(point == ATTACH_RFOOT) return "Right Foot"; + if(point == ATTACH_HIND_LFOOT) return "Left Hind Foot"; + if(point == ATTACH_HIND_RFOOT) return "Right Hind Foot"; + if(point == ATTACH_HUD_CENTER_2) return "HUD Center 2"; + if(point == ATTACH_HUD_TOP_RIGHT) return "HUD Top Right"; + if(point == ATTACH_HUD_TOP_CENTER) return "HUD Top"; + if(point == ATTACH_HUD_TOP_LEFT) return "HUD Top Left"; + if(point == ATTACH_HUD_CENTER_1) return "HUD Center"; + if(point == ATTACH_HUD_BOTTOM_LEFT) return "HUD Bottom Left"; + if(point == ATTACH_HUD_BOTTOM) return "HUD Bottom"; + if(point == ATTACH_HUD_BOTTOM_RIGHT) return "HUD Bottom Right"; + return (string)point; +} + + + + + + + + +// Available bytes for UTF8 packing: + + + + + + + + + + +default +{ + state_entry() + { + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned integer (0 to UTF8_4B_MAX-1) + // Can save a byte or two but importantly provides array-like access + { + integer input = 0x10FFFF - 1; + + string compressed = llChar(1 + input); + + integer output = llOrd(compressed, 0) - 1; + + llOwnerSay( + "Compact int: " + (string)input + " => " + compressed + " => " + (string)output + "\n" + + "Compression " + (string)stringBytes((string)input) + "b => " + (string)stringBytes(compressed) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact unsigned float + { + + float quantizer = (0x7F - 1) / RANGE; + + float input = 1337.256; + + string compressed = llChar(1 + ((integer)(input * quantizer)); + + float output = (llOrd(compressed, 0) - 1) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact signed float + { + + float quantizer = (0x7F - 1) / 2048.0 / 2; + + float input = 1337.256; + + string compressed = llChar(1 + ((integer)(quantizer + input * quantizer)); + + float output = ((llOrd(compressed, 0) - 1) - quantizer) / quantizer; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact vector (region coords <0,0,0> to <256,256,4096>) + { + // Quantization from 0-4096 / 0-256 + float xyquant = (0x7F - 1) / 4096.; + float zquant = (0x7F - 1) / 256.; + vector a = <250.525, 128.128128, 3059.999>; + + // Compress + string b = llChar(1 + ((integer)(a.x * xyquant))) + + llChar(1 + ((integer)(a.y * xyquant))) + + llChar(1 + ((integer)(a.z * zquant))); + + // Decompress + vector c = < + (llOrd(b, 0) - 1) / xyquant, + (llOrd(b, 1) - 1) / xyquant, + (llOrd(b, 2) - 1) / zquant + >; + } + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /// Compact keys (36 -> 8 chars); Keys are really 16 byte values, so we can pack them tightly + // There is probably a better way to do this to fit into the 4 byte UTF-8 chars and deserialise it inline + { + key a = "64064c89-25aa-008b-cfe7-0c34e28ae523"; + + // Compress + string b = llChar(((integer)("0x" + llGetSubString(a, 0, 3)))) + + llChar(((integer)("0x" + llGetSubString(a, 4, 7)))) + + llChar(((integer)("0x" + llGetSubString(a, 9, 12)))) + + llChar(((integer)("0x" + llGetSubString(a, 14, 17)))) + + llChar(((integer)("0x" + llGetSubString(a, 19, 22)))) + + llChar(((integer)("0x" + llGetSubString(a, 24, 27)))) + + llChar(((integer)("0x" + llGetSubString(a, 28, 31)))) + + llChar(((integer)("0x" + llGetSubString(a, 32, 35)))); + + // Decompress + key c = bits2nybbles(llOrd(b, 0)) + bits2nybbles(llOrd(b, 1)) + + "-" + bits2nybbles(llOrd(b, 2)) + "-" + bits2nybbles(llOrd(b, 3)) + + "-" + bits2nybbles(llOrd(b, 4)) + "-" + bits2nybbles(llOrd(b, 5)) + + bits2nybbles(llOrd(b, 6)) + bits2nybbles(llOrd(b, 7)); + + llOwnerSay( + "Compact Key (36 -> 8): " + (string)a + " => '" + b + "' => " + (string)c + "\n" + + "Compression " + (string)stringBytes(a) + "b => " + (string)stringBytes(b) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Compact rotations (<-1,-1,-1,-1> to <1,1,1,1>) + { + float quantizer = 0x10FFFF / 2; + vector a = <-2, 27, 120>; + rotation b = llEuler2Rot(a * DEG_TO_RAD); + + // Compress + string c = llChar(1 + ((integer)(quantizer + b.x * quantizer))) + + llChar(1 + ((integer)(quantizer + b.y * quantizer))) + + llChar(1 + ((integer)(quantizer + b.z * quantizer))) + + llChar(1 + ((integer)(quantizer + b.s * quantizer))); + + // Decompress + rotation d = < + ((llOrd(c, 0) - 1) - quantizer) / quantizer, + ((llOrd(c, 1) - 1) - quantizer) / quantizer, + ((llOrd(c, 2) - 1) - quantizer) / quantizer, + ((llOrd(c, 3) - 1) - quantizer) / quantizer + >; + + vector e = llRot2Euler(d) * RAD_TO_DEG; + + llOwnerSay( + "Compact rot: " + (string)a + " => " + (string)b + " => '" + c + "' => " + (string)d + " => " + (string)e + "\n" + + "Compression " + (string)stringBytes(llList2CSV([a,b])) + "b => " + (string)stringBytes(c) + "b" + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + + + } +} +state limits +{ + state_entry() + { + list limits = [2048.0, 0x7F, 0x07FF, 0xFFFF, 0x10FFFF, + 0xD800, 0xDFFF, 0xFFFE, 0xFFFF]; + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd.lsl new file mode 100644 index 0000000000..fc6df1d1e0 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd.lsl @@ -0,0 +1,12 @@ +// NexiiLSL's scripts/sync-lsd.lsl (include/NexiiLSL/LICENSE): a whole +// script, its numbers as macros; the one it does not use itself is used +// in a state after it. +#include "NexiiLSL/scripts/sync-lsd.lsl" + +state events +{ + state_entry() + { + llOwnerSay((string)EVENT_MESSAGE); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd_expected.lsl new file mode 100644 index 0000000000..e93fd3d8f3 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_sync_lsd_expected.lsl @@ -0,0 +1,644 @@ +// NexiiLSL's scripts/sync-lsd.lsl (include/NexiiLSL/LICENSE): a whole +// script, its numbers as macros; the one it does not use itself is used +// in a state after it. + +/* + Synchronised Linkset Data + + This script synchronises specific linkset data keys across the region that match a prefix, per the config. + + On initialisation, rezzing or changing region it does a full reset/synchronisation with the full + tracked dataset (per scopes) of the network and then simply listens for changes as they propagate across the region. + + Each object can be configured to only sync specific prefixes to optimise bandwidth and processing. + + If you want to persist data across reboots (e.g. if syncing data across temp attach / intermittent objects), + make sure to establish a permanent server object that holds data. +*/ + +// SETTINGS BELOW CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +/// Security +// We use RSA signatures to verify secure communications +string privateKey = "-----REPLACE WITH RSA PRIVATE KEY-----"; +string publicKey = "-----REPLACE WITH RSA PUBLIC KEY-----"; +// We use a protected linkset data key to prevent spoofing due to extracted scripts +// You only need this when using this script in no-mod attachments, e.g. experience temp attach +string protectedName = "SYNC_LSD_PROTECTED"; +string protectedKey = "-----REPLACE WITH SECRET-----"; + +/// Scope prefixes of linkset data keys to synchronise +list scopes = [ + "Test_", + "Foo_", + "Bar_" +]; +// Corresponding listener channels for each scope above +list scopeChannels = [ + 204505, + 204506, + 204507 +]; + +// SETTINGS ABOVE CAN BE CUSTOMISED PER PROJECT //////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// Link Message Numbers + + + +// Event Types Enumeration + + + + + + + + +// Event Bit flags + + + + +list candidates = [/* key object, string scope */]; +list pending = [/* integer action, string name */]; +list verified = [/* key object */]; +list buffer = [/* string name, string value */]; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +integer inScope(string name) { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) return TRUE; + } + return FALSE; +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + +sendData(key target, integer channel, integer eventType, string name, string value) { + integer nameLength = llStringLength(name); + integer available = 1023 - (3 + nameLength); + integer total = getStringBytes(value); + integer isASCII = (total == llStringLength(value)); // Prefer llStringLength as its fastest + + // Fragment the update across multiple messages + if(total > available) + { + eventType = eventType | 64; + while(value) + { + string fragment = ""; + integer width = 0; + while(total > 0) + { + string part = llGetSubString(value, 0, 250); + integer bytes; + if(isASCII) bytes = llStringLength(part); + else bytes = getStringBytes(part); + + if(width + bytes <= available) + { + value = llDeleteSubString(value, 0, 250); + width += bytes; + total -= bytes; + fragment += part; + } + else jump breakFragment; + } + @breakFragment; + + if(value == "") eventType = eventType | 128; + + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + fragment; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } + } + + // Direct message + else + { + string message = llChar(eventType) + llChar(LINKSETDATA_UPDATE + 1) + llChar(nameLength) + name + value; + if(target) llRegionSayTo(target, channel, message); + else llRegionSay(channel, message); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Initialise by pinging all and get latest dataset from network +default +{ + state_entry() + { + if(llLinksetDataReadProtected(protectedName, protectedKey) == "") + { + llDetachFromAvatar(); + llDie(); + return; + } + + llMessageLinked(LINK_SET, 1, "", ""); + + // Reset scoped data, setup listeners and ping everyone + integer iterator = llGetListLength(scopes); + string signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + integer signatureLength = llStringLength(signature); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + llLinksetDataDeleteFound("^" + prefix, ""); + integer channel = llList2Integer(scopeChannels, iterator); + llListen(channel, "", "", ""); + llRegionSay(channel, llChar(1) + llChar(signatureLength) + signature + prefix); + } + + // Timeout from pongs + llSetTimerEvent(0.5); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & 63; + integer eventFlags = header & ~63; + + if(eventType == 2) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Don't add to verified list yet, check scope first + + // Refresh timeout + llSetTimerEvent(FALSE); + llSetTimerEvent(0.5); + + // Add to candidates if scope matches + integer isVerified = (llListFindList(verified, [identifier]) != -1); + list peerScope = llParseString2List(llGetSubString(message, 2 + signatureLength, -1), [llChar(1)], []); + integer peerIterator = llGetListLength(peerScope); + while(peerIterator --> 0) + { + string peerPrefix = llList2String(peerScope, peerIterator); + + // Check if each peer prefix matches any of our scopes + integer ownIterator = llGetListLength(scopes); + while(ownIterator --> 0) + { + string ownPrefix = llList2String(scopes, ownIterator); + + // Phase 1: Check if peer prefix is wider, shorter or exact match + // Is peer prefix wider? + if(llSubStringIndex(peerPrefix, ownPrefix) != 0) + { + // Is peer prefix shorter? + if(llSubStringIndex(ownPrefix, peerPrefix) != 0) + { + // Peer prefix doesnt match our scope to track, so skip it + jump nextPeerPrefix; + } + } + + if(!isVerified) + { + // Mark as verified + integer iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + verified += identifier; + isVerified = TRUE; + } + + // Phase 2: Compare with candidates; Check if already exact match or is shorter, otherwise delete any wider prefixes + integer candidatesIterator = llGetListLength(candidates); + while(candidatesIterator > 0) + { + candidatesIterator -= 2; + string candidatePrefix = llList2String(candidates, candidatesIterator + 1); + + if(peerPrefix == candidatePrefix) + jump nextPeerPrefix; // Already have this candidate; + + // Is candidate shorter than peer? + else if(llSubStringIndex(peerPrefix, candidatePrefix) == 0) + jump nextPeerPrefix; // Skip adding peer as candidate as we already have a better match + + // Is candidate wider than peer? + else if(llSubStringIndex(candidatePrefix, peerPrefix) == 0) + // Delete candidate as peer is a better match + candidates = llDeleteSubList(candidates, candidatesIterator, candidatesIterator + 1); + } + + // Phase 3: Add peer as candidate + candidates += [identifier, peerPrefix]; + + @nextPeerPrefix; + } + @nextPeer; + } + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Sync event + if(eventType == 4) + { + // integer action = llOrd(message, 1) - 1; + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + string value = llGetSubString(message, 3 + nameLength, -1); + + if(!inScope(name)) return; + + if(eventFlags & 64) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & 128) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + llLinksetDataWrite(name, value); + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else llLinksetDataWrite(name, value); + } + + else if(eventType == 5) + { + // If no more candidates, move to sync state + if(candidates); else state sync; + + // Pull out the next candidate to sync + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(3) + llDumpList2String(peerScopes, llChar(1)) + ); + } + } + + timer() + { + llSetTimerEvent(FALSE); + + // If no candidates, move directly to sync state + if(candidates); else state sync; + + // Sort candidates by key + candidates = llListSort(candidates, 2, TRUE); + + // Pull out a candidate + key peer = llList2Key(candidates, 0); + list peerScopes = [llList2String(candidates, 1)]; + candidates = llDeleteSubList(candidates, 0, 1); + + // Merge other scopes for same peer + while(llList2Key(candidates, 0) == peer) + { + peerScopes += llList2String(candidates, 1); + candidates = llDeleteSubList(candidates, 0, 1); + } + + llRegionSayTo(peer, llList2Integer(scopeChannels, llListFindList(scopes, [llList2String(peerScopes, 0)])), + llChar(3) + llDumpList2String(peerScopes, llChar(1)) + ); + } + + state_exit() + { + llMessageLinked(LINK_SET, 2, "", ""); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Idle away, synchronising data as needed +state sync +{ + on_rez(integer param) { llResetScript(); } + changed(integer change) { if(change & CHANGED_REGION) llResetScript(); } + state_entry() + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) llListen(llList2Integer(scopeChannels, iterator), "", "", ""); + } + + listen(integer channel, string name, key identifier, string message) + { + integer header = llOrd(message, 0); + integer eventType = header & 63; + integer eventFlags = header & ~63; + + if(eventType == 1) + { + // Parse packet and verify RSA signature + integer signatureLength = llOrd(message, 1); + string signature = llGetSubString(message, 2, 1 + signatureLength); + string expected = (string)identifier + " " + llGetDate(); + if(!llVerifyRSA(publicKey, expected, signature, "sha512")) return; + + // Check if peer prefix matches any of our scopes + integer hasMatch = FALSE; + list matchedScopes; + string peerPrefix = llGetSubString(message, 2 + signatureLength, -1); + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string ownPrefix = llList2String(scopes, iterator); + if(llSubStringIndex(peerPrefix, ownPrefix) == 0) matchedScopes += ownPrefix; + else if(llSubStringIndex(ownPrefix, peerPrefix) == 0) matchedScopes += ownPrefix; + } + if(matchedScopes); else return; + + // Mark as verified + iterator = llGetListLength(verified); + while(iterator --> 0) + { + if(llKey2Name(llList2Key(verified, iterator)) == "") + verified = llDeleteSubList(verified, iterator, iterator); + } + if(llListFindList(verified, [identifier]) == -1) verified += identifier; + + // Respond with pong + signature = llSignRSA(privateKey, (string)llGetKey() + " " + llGetDate(), "sha512"); + signatureLength = llStringLength(signature); + llRegionSayTo(identifier, channel, + llChar(2) + + llChar(signatureLength) + + signature + + llDumpList2String(matchedScopes, llChar(1)) + ); + } + + else if(llListFindList(verified, [identifier]) == -1) return; + + + // Remote linkset data event + if(eventType == 6) + { + integer action = llOrd(message, 1) - 1; + + if(action == LINKSETDATA_UPDATE) + { + integer nameLength = llOrd(message, 2); + string name = llGetSubString(message, 3, 2 + nameLength); + + if(!inScope(name)) return; + + string value = llGetSubString(message, 3 + nameLength, -1); + + if(eventFlags & 64) + { + integer pointer = llListFindStrided(buffer, [name], 0, -1, 2); + if(pointer != -1) value = llList2String(buffer, pointer + 1) + value; + + if(eventFlags & 128) + { + buffer = llDeleteSubList(buffer, pointer, pointer + 1); + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + else if(pointer == -1) buffer += [name, value]; + else buffer = llListReplaceList(buffer, [name, value], pointer, pointer + 1); + } + else + { + if(llLinksetDataWrite(name, value) == LINKSETDATA_OK) pending += [action, name]; + } + } + + else if(action == LINKSETDATA_DELETE) + { + string name = llGetSubString(message, 2, -1); + if(!inScope(name)) return; + if(llLinksetDataDelete(name) == LINKSETDATA_OK) pending += [action, name]; + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + integer prefixLength = llOrd(message, 2); + string prefix = llGetSubString(message, 3, 2 + prefixLength); + // if(!inScope(prefix)) return; -- This is complex as the source may be wider or narrower than our scopes but still have relevant data, hmm... + // For now we cheat a bit because delete found can tell us how many were deleted + // There is however danger for overlap between local and synced data if they have the same names but weren't intended to be synced + + // Escape regex special characters and prepare bucket as a regex pattern + string bucket = llGetSubString(message, 3 + prefixLength, -1); + bucket = llReplaceSubString(bucket, ".", "\\.", 0); + bucket = llReplaceSubString(bucket, "^", "\\^", 0); + bucket = llReplaceSubString(bucket, "$", "\\$", 0); + bucket = llReplaceSubString(bucket, "*", "\\*", 0); + bucket = llReplaceSubString(bucket, "+", "\\+", 0); + bucket = llReplaceSubString(bucket, "?", "\\?", 0); + bucket = llReplaceSubString(bucket, "(", "\\(", 0); + bucket = llReplaceSubString(bucket, ")", "\\)", 0); + bucket = llReplaceSubString(bucket, "{", "\\{", 0); + bucket = llReplaceSubString(bucket, "}", "\\}", 0); + bucket = llReplaceSubString(bucket, "|", "\\|", 0); + bucket = "^" + prefix + "(" + llReplaceSubString(bucket, llChar(1), "|", 0) + ")$"; + integer deleted = llList2Integer(llLinksetDataDeleteFound(bucket, ""), 0); + if(deleted) pending += [action, ""]; + } + + else if(action == LINKSETDATA_RESET) + { + string prefix = llGetSubString(message, 2, -1); + if(!inScope(prefix)) return; + // llLinksetDataReset(); -- Takes out all data even locally scoped, so we convert it to a multi-delete instead + integer deleted = llList2Integer(llLinksetDataDeleteFound("^" + prefix, ""), 0); + if(deleted) pending += [LINKSETDATA_MULTIDELETE, ""]; + } + } + + // TODO: Would this be desirable? Would have to specify a common channel though. The idea would be to piggyback on the secure comms to send messages across + // // Remote link message event + // else if(eventType == EVENT_MESSAGE) + // { + // integer textLength = llOrd(message, 1); + // string text = llGetSubString(message, 2, 1 + textLength); + // string text2 = llGetSubString(message, 2 + textLength, -1); + // llMessageLinked(LINK_SET, MESSAGE_SYNC, text, text2); + // } + + // Full synchronisation with peer + else if(eventType == 3) + { + list requestScopes; + integer requestIterator; + integer requestTotal; + if(llStringLength(message) > 1) + { + requestScopes = llParseString2List(llGetSubString(message, 1, -1), [llChar(1)], []); + requestTotal = llGetListLength(requestScopes); + } + + integer sent = 0; + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string pattern = "^" + llList2String(scopes, iterator); + list names = llLinksetDataFindKeys(pattern, 0, FALSE); // TODO: Change to paginate the results + integer nameIterator = llGetListLength(names); + while(nameIterator --> 0) + { + string name = llList2String(names, nameIterator); + if(requestTotal) + { + for(requestIterator = 0; requestIterator < requestTotal; ++requestIterator) + { + string requestPrefix = llList2String(requestScopes, requestIterator); + if(llSubStringIndex(name, requestPrefix) == 0) jump requestMatch; + } + jump requestSkip; + } + @requestMatch; + + string value = llLinksetDataRead(name); + sendData(identifier, channel, 4, name, value); + if((++sent % 32) == 0) llSleep(4/45.); // Avoid flooding + + @requestSkip; + } + } + + llRegionSayTo(identifier, channel, llChar(5)); + } + } + + linkset_data(integer action, string name, string value) + { + // Skip if this was expected and a change by this script from an external source + if(pending) + { + integer pendingAction = llList2Integer(pending, 0); + string pendingName = llList2String(pending, 1); + if(action == pendingAction) + { + if((action == LINKSETDATA_UPDATE || action == LINKSETDATA_DELETE) && name == pendingName) + { pending = llDeleteSubList(pending, 0, 1); return; } + + else // MULTIDELETE or RESET + { pending = llDeleteSubList(pending, 0, 1); return; } + } + } + + if(action == LINKSETDATA_UPDATE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + return sendData(NULL_KEY, channel, 6, name, value); + } + } + } + + else if(action == LINKSETDATA_DELETE) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(6) + llChar(action + 1) + name); + return; + } + } + } + + else if(action == LINKSETDATA_RESET) + { + integer iterator = llGetListLength(scopes); + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(6) + llChar(action + 1) + prefix); + } + } + + else if(action == LINKSETDATA_MULTIDELETE) + { + list deleted = llParseString2List(name, [","], []); + list known; + integer total = llGetListLength(scopes); + integer iterator = total; + while(iterator --> 0) known += ""; + + do { + name = llList2String(deleted, 0); + deleted = llDeleteSubList(deleted, 0, 0); + + iterator = total; + while(iterator --> 0) + { + string prefix = llList2String(scopes, iterator); + if(llSubStringIndex(name, prefix) == 0) + { + name = llDeleteSubString(name, 0, llStringLength(prefix) - 1); + + string bucket = llList2String(known, iterator); + if(bucket) bucket += llChar(1) + name; else bucket += name; + known = llListReplaceList(known, [bucket], iterator, iterator); + } + } + } while(deleted); + + iterator = total; + while(iterator --> 0) + { + string bucket = llList2String(known, iterator); + if(bucket) + { + string prefix = llList2String(scopes, iterator); + integer channel = llList2Integer(scopeChannels, iterator); + llRegionSay(channel, llChar(6) + llChar(action + 1) + llChar(llStringLength(prefix)) + prefix + bucket); + // TODO: Check if we need to fragment the bucket across multiple messages + } + } + } + } +} + +state events +{ + state_entry() + { + llOwnerSay((string)7); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_texture.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_texture.lsl new file mode 100644 index 0000000000..5ff01a28b9 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_texture.lsl @@ -0,0 +1,23 @@ +// NexiiLSL's texture.lsl (include/NexiiLSL/LICENSE): macros whose bodies +// run over several lines, a backslash straight after a comma or an open +// bracket, calls to another of them inside a body over several lines, a +// cast in a body, numbers written .5 and 2., and a name a body takes from +// where it is used (`digit`) rather than from its parameters. +#include "NexiiLSL/texture.lsl" + +default +{ + state_entry() + { + integer digit = 3; + float width = 10; + float height = 20; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 0, TEXTURE_CURSORS, CURSOR(1, 2), 0]); + list repeat = [TEXTURE_REPEAT(1, 2, 64, 128)]; + vector offset = TEXTURE_OFFSET(1, 2, 64, 128); + list coords = [TEXTURE_COORDS(1, 2, 3, 4, 64, 128)]; + list rect = [TEXTURE_RECT(1, 2, 3, 4, 64, 128)]; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 1, TEXTURE_CURSORS, SPRITESHEET(5, 64, 1024), 0]); + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 2, TEXTURE_CURSORS, SPRITESHEET2(5, 32, 16, 512, 256), 0]); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_texture_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_texture_expected.lsl new file mode 100644 index 0000000000..6f833ab1d2 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_texture_expected.lsl @@ -0,0 +1,104 @@ +// NexiiLSL's texture.lsl (include/NexiiLSL/LICENSE): macros whose bodies +// run over several lines, a backslash straight after a comma or an open +// bracket, calls to another of them inside a body over several lines, a +// cast in a body, numbers written .5 and 2., and a name a body takes from +// where it is used (`digit`) rather than from its parameters. + +// This is the heart of managing texture coordinates, by converting pixel coordinates into SL texture uv coords + + + + + + + + + + + + + + + +// For rendering icons placed neatly into an even grid layout in a square texture resolution + + + + + + + + +// For non-uniform grid layouts as well as textures with non-square aspect ratio + + + + + + + + + +// Compact spritesheet functions by hardcoding resolution and icon sizes + + +// equivalent to #define CURSOR(a, b) TEXTURE_REPEAT(256, 256, 2048., 512.), TEXTURE_OFFSET(, , 2048., 512.) + + + +// Old macros moved from utilities.lsl +// #define TEXTURE_SCALE(w, h) +// #define TEXTURE_OFFSET(w, h, x, y) <(w*.5 + x - RESOLUTION*.5) / RESOLUTION, (RESOLUTION*.5 - (h*.5 + y)) / RESOLUTION, 0> +// #define TEXTURE_SIZE(w, h) w / RESOLUTION*.5, h / RESOLUTION*.5 + + +/* +Texture maths, pixels to coords + +default +{ + state_entry() + { + #define RESOLUTION 1024. + #define FACE 4 + + float width = 181.5; + float height = 116.5; + float x = 30; + float y = 608; + + llScaleTexture( + width / RESOLUTION, + height / RESOLUTION, + FACE + ); + llOffsetTexture( + (width*.5 + x - RESOLUTION*.5) / RESOLUTION, + (RESOLUTION*.5 - (height*.5 + y)) / RESOLUTION, + FACE + ); + llSetScale(< + 0.04, + width/RESOLUTION*.5, + height/RESOLUTION*.5 + >); + } +} + +*/ +default +{ + state_entry() + { + integer digit = 3; + float width = 10; + float height = 20; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 0, "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd", <256/2048., 256/512., 0>, <(128 + (256*1) - 1024)/2048., (256 - 128 + (256*2))/512., 0>, 0]); + list repeat = []; + vector offset = <((1) - ((float)(64))/2) / ((float)(64)), -((2) - ((float)(128))/2) / ((float)(128)), 0>; + list coords = [<3 / ((float)(64)), 4 / ((float)(128)), 0>, <((1) - ((float)(64))/2.) / ((float)(64)), -((2) - ((float)(128))/2.) / ((float)(128)), 0>]; + list rect = [<3 / ((float)(64)), 4 / ((float)(128)), 0>, <((1) + 3/2. - ((float)(64))/2.) / ((float)(64)), -((2) + 4/2. - ((float)(128))/2.) / ((float)(128)), 0>]; + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 1, "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd", <64 / ((float)(1024)), 64 / ((float)(1024)), 0>, <((64*.5 + 64 * 5 % (1024/64)) - ((float)(1024))/2.) / ((float)(1024)), -((64*.5 - 64 * 5 / llFloor(1024/64)) - ((float)(1024))/2.) / ((float)(1024)), 0>, 0]); + llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_TEXTURE, 2, "18dcdc40-eb58-c42f-b5ef-5e37d7f5f8bd", <32 / ((float)(512)), 16 / ((float)(256)), 0>, <((32*.5 + 32 * digit % llFloor(512/32)) - ((float)(512))/2.) / ((float)(512)), -((16*.5 - 16 * digit / llFloor(512/32)) - ((float)(256))/2.) / ((float)(256)), 0>, 0]); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_utilities.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities.lsl new file mode 100644 index 0000000000..3c63ee01ee --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities.lsl @@ -0,0 +1,12 @@ +// NexiiLSL's utilities.lsl (include/NexiiLSL/LICENSE), its macros called +// where a condition goes. +#include "NexiiLSL/utilities.lsl" + +default +{ + collision_start(integer count) + { + if(isSameOwner(llDetectedKey(0))) llOwnerSay("mine, the " + (string)count + nth(count)); + if(notSameOwner(llDetectedKey(0))) llOwnerSay((string)stringBytes(llDetectedName(0))); + } +} diff --git a/indra/alscript/tests/preprocessor/test_nexiilsl_utilities_expected.lsl b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities_expected.lsl new file mode 100644 index 0000000000..a79b576ba6 --- /dev/null +++ b/indra/alscript/tests/preprocessor/test_nexiilsl_utilities_expected.lsl @@ -0,0 +1,125 @@ +// NexiiLSL's utilities.lsl (include/NexiiLSL/LICENSE), its macros called +// where a condition goes. + + + + +// For displaying ordinal numbers like 1st, 2nd, 3rd, 20th, 21st, 22nd, etc. +string nth(integer value) +{ + value = value % 10; + if(value == 1) return "st"; + if(value == 2) return "nd"; + if(value == 3) return "rd"; + return "th"; +} + +// Returns the number of bytes a string would take up in UTF-8 encoding +integer stringBytes(string str) { + integer bytes; + integer length = llStringLength(str); + integer iterator; + for(; iterator < length; ++iterator) + { + integer byte = llOrd(str, iterator); + if(byte <= 0x7F) bytes += 1; + else if(byte <= 0x07FF) bytes += 2; + else if(byte <= 0xFFFF) bytes += 3; + else bytes += 4; + } + return bytes; +} + +// Significantly faster +// From https://wiki.secondlife.com/wiki/LlStringToBase64 +integer getStringBytes(string msg) { + return (llStringLength((string)llParseString2List(llStringToBase64(msg), ["="], [])) * 3) >> 2; +} + + +// From https://wiki.secondlife.com/wiki/Efficient_Hex +string bits2nybbles(integer bits) { + integer lsn; // least significant nybble + string nybbles = ""; + do + nybbles = llGetSubString("0123456789abcdef", lsn = (bits & 0xF), lsn) + nybbles; + while (bits = (0xfffFFFF & (bits >> 4))); + return nybbles; +} + + +string AttachmentPointAsName(integer point) +{ + if(point == ATTACH_HEAD) return "Skull"; + if(point == ATTACH_NOSE) return "Nose"; + if(point == ATTACH_MOUTH) return "Mouth"; + if(point == ATTACH_FACE_TONGUE) return "Tongue"; + if(point == ATTACH_CHIN) return "Chin"; + if(point == ATTACH_FACE_JAW) return "Jaw"; + if(point == ATTACH_LEAR) return "Left Ear"; + if(point == ATTACH_REAR) return "Right Ear"; + if(point == ATTACH_FACE_LEAR) return "Alt Left Ear"; + if(point == ATTACH_FACE_REAR) return "Alt Right Ear"; + if(point == ATTACH_LEYE) return "Left Eye"; + if(point == ATTACH_REYE) return "Right Eye"; + if(point == ATTACH_FACE_LEYE) return "Alt Left Eye"; + if(point == ATTACH_FACE_REYE) return "Alt Right Eye"; + if(point == ATTACH_NECK) return "Neck"; + if(point == ATTACH_LSHOULDER) return "Left Shoulder"; + if(point == ATTACH_RSHOULDER) return "Right Shoulder"; + if(point == ATTACH_LUARM) return "L Upper Arm"; + if(point == ATTACH_RUARM) return "R Upper Arm"; + if(point == ATTACH_LLARM) return "L Lower Arm"; + if(point == ATTACH_RLARM) return "R Lower Arm"; + if(point == ATTACH_LHAND) return "Left Hand"; + if(point == ATTACH_RHAND) return "Right Hand"; + if(point == ATTACH_LHAND_RING1) return "Left Ring Finger"; + if(point == ATTACH_RHAND_RING1) return "Right Ring Finger"; + if(point == ATTACH_LWING) return "Left Wing"; + if(point == ATTACH_RWING) return "Right Wing"; + if(point == ATTACH_CHEST) return "Chest"; + if(point == ATTACH_LEFT_PEC) return "Left Pec"; + if(point == ATTACH_RIGHT_PEC) return "Right Pec"; + if(point == ATTACH_BELLY) return "Stomach"; + if(point == ATTACH_BACK) return "Spine"; + if(point == ATTACH_TAIL_BASE) return "Tail Base"; + if(point == ATTACH_TAIL_TIP) return "Tail Tip"; + if(point == ATTACH_AVATAR_CENTER) return "Avatar Center"; + if(point == ATTACH_PELVIS) return "Pelvis"; + if(point == ATTACH_GROIN) return "Groin"; + if(point == ATTACH_LHIP) return "Left Hip"; + if(point == ATTACH_RHIP) return "Right Hip"; + if(point == ATTACH_LULEG) return "L Upper Leg"; + if(point == ATTACH_RULEG) return "R Upper Leg"; + if(point == ATTACH_RLLEG) return "R Lower Leg"; + if(point == ATTACH_LLLEG) return "L Lower Leg"; + if(point == ATTACH_LFOOT) return "Left Foot"; + if(point == ATTACH_RFOOT) return "Right Foot"; + if(point == ATTACH_HIND_LFOOT) return "Left Hind Foot"; + if(point == ATTACH_HIND_RFOOT) return "Right Hind Foot"; + if(point == ATTACH_HUD_CENTER_2) return "HUD Center 2"; + if(point == ATTACH_HUD_TOP_RIGHT) return "HUD Top Right"; + if(point == ATTACH_HUD_TOP_CENTER) return "HUD Top"; + if(point == ATTACH_HUD_TOP_LEFT) return "HUD Top Left"; + if(point == ATTACH_HUD_CENTER_1) return "HUD Center"; + if(point == ATTACH_HUD_BOTTOM_LEFT) return "HUD Bottom Left"; + if(point == ATTACH_HUD_BOTTOM) return "HUD Bottom"; + if(point == ATTACH_HUD_BOTTOM_RIGHT) return "HUD Bottom Right"; + return (string)point; +} + + + + + + + + +default +{ + collision_start(integer count) + { + if(llGetOwnerKey(llDetectedKey(0)) == llGetOwner()) llOwnerSay("mine, the " + (string)count + nth(count)); + if(llGetOwnerKey(llDetectedKey(0)) != llGetOwner()) llOwnerSay((string)stringBytes(llDetectedName(0))); + } +} From 512e84b092a98ea824a58b1b69a781cfa07d0132 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 17:28:27 -0400 Subject: [PATCH 16/41] Vim leaves insert mode with one caret, and the live mirrors go Vim has one caret: what insert mode typed at several stays, and Escape leaves the main one alone before a count or a block puts its text in again, there only. A composition stands at the main caret only, the others moving aside for it and back, and what it commits is typed at each; the find bar is seeded from the main selection. Both were so already, and are tested. Mirrors brought up as each key was typed were Select Next Occurrence's, which makes selections now: nothing set them live any more. Their highlight, the undo and redo that ended them, the key's scope that kept them up, and a mirror added as it was found go; a snippet's mirrors, brought up as their stop is left, stay. Co-Authored-By: Claude Opus 5.5 --- indra/llui/alcodeeditor.cpp | 90 ++------------------------- indra/llui/alcodeeditor.h | 8 +-- indra/llui/alsnippetsession.cpp | 2 - indra/llui/alsnippetsession.h | 7 --- indra/llui/alvimkeymap.cpp | 3 + indra/llui/tests/altextview_test.cpp | 34 ++++++++++ indra/llui/tests/alvimkeymap_test.cpp | 18 ++++++ 7 files changed, 63 insertions(+), 99 deletions(-) diff --git a/indra/llui/alcodeeditor.cpp b/indra/llui/alcodeeditor.cpp index d685b6b766..d25da286da 100644 --- a/indra/llui/alcodeeditor.cpp +++ b/indra/llui/alcodeeditor.cpp @@ -56,22 +56,6 @@ static LLDefaultChildRegistry::Register r("code_editor"); -namespace -{ - // What is left to do as a scope ends, whichever way it ends. - template - struct AtScopeEnd - { - F f; - ~AtScopeEnd() { f(); } - }; - template - AtScopeEnd atScopeEnd(F f) - { - return AtScopeEnd{ std::move(f) }; - } -} - namespace { const S32 GUTTER_PAD = 6; @@ -1711,19 +1695,6 @@ void ALCodeEditor::drawRowExtras(S32 line, S32 row, const LLRect& text, S32 scre } } } - // The places of a text being changed at once, as its stop is. - if (mSnippet.live()) - { - for (const ALSnippetSession::Mirror& mirror : mSnippet.mirrors()) - { - F32 x0, x1; - if (mirror.range.begin.line <= line && line <= mirror.range.end.line && spanOnRow(line, row, mirror.range, x0, x1)) - { - gl_rect_2d(static_cast(left + x0), screen_top, static_cast(left + x1), screen_top - row_h, highlightColor() % alpha); - gl_rect_2d(static_cast(left + x0), screen_top, static_cast(left + x1), screen_top - row_h, mBracketMatchColor.get() % alpha, false); - } - } - } for (S32 i = 0; i < static_cast(mSnippet.stops().size()); ++i) { F32 x0, x1; @@ -3452,10 +3423,9 @@ void ALCodeEditor::insertSnippet(std::string_view body) setSelection(mSnippet.stops()[0]); } -void ALCodeEditor::syncMirrors(S32 index, bool grouped) +void ALCodeEditor::syncMirrors(S32 index) { - // As one edit, one step to undo -- or part of the key's -- and the - // selection as it was. + // As one edit, one step to undo, and the selection as it was. std::string wanted; const std::vector order = mSnippet.staleMirrors(index, document(), wanted); if (order.empty()) @@ -3470,17 +3440,11 @@ void ALCodeEditor::syncMirrors(S32 index, bool grouped) edits.emplace_back(mSnippet.mirrors()[static_cast(k)].range, wanted); } // All of them as one edit. - if (grouped) - { - undoJournal().beginGroup(); - } + undoJournal().beginGroup(); mSnippet.syncingAll(); editMany(std::move(edits), was.end); mSnippet.syncing(-1); - if (grouped) - { - undoJournal().endGroup(); - } + undoJournal().endGroup(); placeSelection(document().clamp(was.begin), document().clamp(was.end)); afterEdit(); } @@ -3605,26 +3569,6 @@ bool ALCodeEditor::changeAllOccurrences() return true; } -void ALCodeEditor::undo() -{ - // A step back is the text as it was, the places with it: nothing to - // bring up to what the stop holds. - if (mSnippet.live()) - { - clearPlaceholders(); - } - ALTextView::undo(); -} - -void ALCodeEditor::redo() -{ - if (mSnippet.live()) - { - clearPlaceholders(); - } - ALTextView::redo(); -} - void ALCodeEditor::dropPlaceholdersLeft() { if (!mSnippet.active() || mSnippet.syncing()) @@ -4202,25 +4146,6 @@ void ALCodeEditor::dropTyping() bool ALCodeEditor::handleKeyHere(KEY key, MASK mask) { - // Every place of a text being changed at once: what the key did, and - // the places brought up to it, one step with the typing around it -- - // but for a step back or forward, which ends it. - const ALEditorCommand command = keymap().lookup(key, mask); - const bool live = mSnippet.live() && command != ALEditorCommand::Undo && command != ALEditorCommand::Redo; - if (live) - { - undoJournal().beginTyping(selection()); - } - const auto keep_up = atScopeEnd([this, live]() { - if (live) - { - if (mSnippet.live()) - { - syncMirrors(mSnippet.at(), false); - } - undoJournal().endTyping(); - } - }); hideCard(); // At several carets the signature and a snippet's stops are not in // play, each being about one place; the list is the main caret's. @@ -4417,16 +4342,11 @@ bool ALCodeEditor::handleUnicodeCharHere(llwchar uni_char) clearPlaceholders(); } // The pair, the character and its outdent one key typed, one with the - // typing around it -- and every place of a text being changed at once - // brought up to it in the same step. + // typing around it. undoJournal().beginTyping(selection()); const bool paired = typing && mAutoClose && uni_char < 0x80 && !isReadOnly() && (several ? typePairs(static_cast(uni_char)) : typePair(static_cast(uni_char))); const bool typed = paired || ALTextView::handleUnicodeCharHere(uni_char); - if (typed && mSnippet.live()) - { - syncMirrors(mSnippet.at(), false); - } undoJournal().endTyping(); if (!typed) { diff --git a/indra/llui/alcodeeditor.h b/indra/llui/alcodeeditor.h index 69ba6b2a55..3d39aee035 100644 --- a/indra/llui/alcodeeditor.h +++ b/indra/llui/alcodeeditor.h @@ -552,8 +552,6 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures // names only. bool selectNextOccurrence(); bool changeAllOccurrences(); - void undo() override; - void redo() override; // The names of a signature's parameters, and where their list opens // (ALSnippetSession). static std::vector parameterNames(std::string_view detail, std::string_view name = std::string_view()) @@ -1076,9 +1074,9 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures bool mCompletionMoved = false; // The stops of a snippet or a call being filled in (ALSnippetSession). ALSnippetSession mSnippet; - // Each placeholder's mirrors made what it holds: as one step of its - // own, or as part of the key being typed. - void syncMirrors(S32 index, bool grouped = true); + // Each placeholder's mirrors made what it holds, as one step of its + // own. + void syncMirrors(S32 index); // The places after `from` that read as `wanted`, going round, whole // names only where asked, none over any of `taken` -- the selections // there are; at most `most` of them. diff --git a/indra/llui/alsnippetsession.cpp b/indra/llui/alsnippetsession.cpp index 647f6612d6..5424975805 100644 --- a/indra/llui/alsnippetsession.cpp +++ b/indra/llui/alsnippetsession.cpp @@ -327,12 +327,10 @@ void ALSnippetSession::start(std::vector stops, const ALTextPos& af mMirrors = std::move(mirrors); mLanding = landing; mSyncing = -1; - mLive = false; } void ALSnippetSession::clear() { - mLive = false; mMirrors.clear(); mLanding = 0; mStops.clear(); diff --git a/indra/llui/alsnippetsession.h b/indra/llui/alsnippetsession.h index e64abef61d..a02af29d91 100644 --- a/indra/llui/alsnippetsession.h +++ b/indra/llui/alsnippetsession.h @@ -90,12 +90,6 @@ class ALSnippetSession const ALTextPos& after() const { return mAfter; } S32 landing() const { return mLanding; } const std::vector& mirrors() const { return mMirrors; } - // Another place that repeats a stop, added as it is found. - void addMirror(Mirror mirror) { mMirrors.push_back(std::move(mirror)); } - // Whether the mirrors are brought up as each key is typed, rather than - // as the stop is left: every place of a name being changed at once. - void setLive(bool live) { mLive = live; } - bool live() const { return mLive && active(); } // An edit heard: the stop being typed over becomes what was typed, the // others move with the text, and one the edit cut into goes, with its @@ -124,7 +118,6 @@ class ALSnippetSession ALTextPos mAfter; std::vector mMirrors; S32 mSyncing = -1; - bool mLive = false; // How long the text `${0:text}` put where the caret lands is, to be // chosen as it lands; nothing for a bare $0. S32 mLanding = 0; diff --git a/indra/llui/alvimkeymap.cpp b/indra/llui/alvimkeymap.cpp index b00e66ace3..c27417a704 100644 --- a/indra/llui/alvimkeymap.cpp +++ b/indra/llui/alvimkeymap.cpp @@ -3782,6 +3782,9 @@ void ALVimKeymap::enterInsert(ALTextView& view, S32 count, bool grouped) void ALVimKeymap::leaveInsert(ALTextView& view) { + // Vim has one caret: the others typing went in at go, before a count + // or a block puts in again at the main one. + view.singleSelection(); const ALTextDocument& d = view.document(); // What was typed, again as many times as the count said: made once and // put in as one edit, not an edit a time. diff --git a/indra/llui/tests/altextview_test.cpp b/indra/llui/tests/altextview_test.cpp index accc71df40..f9f2a1a7b5 100644 --- a/indra/llui/tests/altextview_test.cpp +++ b/indra/llui/tests/altextview_test.cpp @@ -2355,4 +2355,38 @@ namespace tut v.setReadOnly(true); ensure("none in a read-only text", !v.canPerform(ALEditorCommand::ColumnSelectDown) && !v.perform(ALEditorCommand::ColumnSelectDown)); } + + template<> template<> + void altextview_object::test<74>() + { + set_test_name("with several carets a composition stands at the main one only, the others moving aside for it and back, and what it commits is typed at each"); + ALTextView& v = make("abcd\nabcd"); + v.setSelections(caretRange(0, 1), { caretRange(0, 3), caretRange(1, 1) }); + LLPreeditor& ime = v.preeditor(); + ime.updatePreedit("XY", { 2 }, { false }, 2); + ensure_equals("at the main caret only", v.text(), std::string("aXYbcd\nabcd")); + ensure_equals("the others moved aside for it", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 5), caretRange(1, 1) })); + ime.resetPreedit(); + ensure_equals("gone", v.text(), std::string("abcd\nabcd")); + ensure_equals("and they back", rangesSaid(v.otherSelections()), rangesSaid({ caretRange(0, 3), caretRange(1, 1) })); + ensure("the main one where it began", v.selection() == caretRange(0, 1)); + ensure("none of it an edit", !v.isDirty()); + // What the input method commits comes as typed characters. + type("Z"); + ensure_equals("committed: typed at each", v.text(), std::string("aZbcZd\naZbcd")); + } + + template<> template<> + void altextview_object::test<75>() + { + set_test_name("with several selections the find bar is seeded from the main one"); + ALTextView& v = make("cat dog\nbird cat"); + v.setSelections(spanRange(0, 4, 7), { spanRange(1, 0, 4) }); + v.showFind(false); + ensure_equals("the main selection's text", v.findBar()->query(), std::string("dog")); + v.hideFind(); + v.setSelections(caretRange(1, 6), { spanRange(0, 4, 7) }); + v.showFind(false); + ensure_equals("the word at the main caret, not another's selection", v.findBar()->query(), std::string("cat")); + } } diff --git a/indra/llui/tests/alvimkeymap_test.cpp b/indra/llui/tests/alvimkeymap_test.cpp index d232f72974..7db704114a 100644 --- a/indra/llui/tests/alvimkeymap_test.cpp +++ b/indra/llui/tests/alvimkeymap_test.cpp @@ -2818,4 +2818,22 @@ namespace tut ensure_equals("gone to", e.caret().line, 44); ensure("and below them there: " + std::to_string(below) + " of " + std::to_string(row_h), below >= 2 * row_h); } + + template<> template<> + void alvimkeymap_object::test<95>() + { + set_test_name("vim has one caret: what insert mode typed at several stays, Escape leaves the main caret alone, stepped back onto it, and a count types again there only"); + ALCodeEditor& e = make("one\ntwo\nthree"); + e.setSelections(ALTextRange(ALTextPos(2, 0), ALTextPos(2, 0)), { ALTextRange(ALTextPos(0, 0), ALTextPos(0, 0)), ALTextRange(ALTextPos(1, 0), ALTextPos(1, 0)) }); + keys("iab"); + ensure_equals("typed at each", e.text(), std::string("abone\nabtwo\nabthree")); + ensure("still several while inserting", e.hasOtherSelections()); + keys(""); + ensure("one caret in normal mode", !e.hasOtherSelections()); + ensure("the main one, on the last character it typed", e.caret() == ALTextPos(2, 1)); + e.setSelections(ALTextRange(ALTextPos(2, 0), ALTextPos(2, 0)), { ALTextRange(ALTextPos(0, 0), ALTextPos(0, 0)), ALTextRange(ALTextPos(1, 0), ALTextPos(1, 0)) }); + keys("3iX"); + ensure_equals("once at each, the count's again at the main one only", e.text(), std::string("Xabone\nXabtwo\nXXXabthree")); + ensure("one caret", !e.hasOtherSelections()); + } } From eb95dca3d2447f2695379fbc8bc35385e15e1d48 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 18:00:17 -0400 Subject: [PATCH 17/41] A caret on a folded line opens it, and folding moves every caret out A caret besides the main one put on a line folded away -- by Select Next Occurrence, Select All Matches, a column, an undo -- was not drawn, and nothing opened its line. Its line opens now, as the main one's does. Folding a block, or everything, moves the others in it to the end of the line the fold shows, as it already moved the main one; those that meet there become one. Co-Authored-By: Claude Opus 5.5 --- indra/llui/alcodeeditor.cpp | 34 ++++++++++++++++++++++++++ indra/llui/alcodeeditor.h | 3 +++ indra/llui/altextview.cpp | 27 +++++++++++++++++--- indra/llui/tests/alcodeeditor_test.cpp | 23 +++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/indra/llui/alcodeeditor.cpp b/indra/llui/alcodeeditor.cpp index d25da286da..e4b3214dca 100644 --- a/indra/llui/alcodeeditor.cpp +++ b/indra/llui/alcodeeditor.cpp @@ -1976,9 +1976,42 @@ bool ALCodeEditor::foldAt(S32 line) setCaret(document().lineEnd(chosen->start)); } applyFolds(); + othersOutOfFolds(); return true; } +void ALCodeEditor::othersOutOfFolds() +{ + if (!hasOtherSelections() || !layout().anyHidden()) + { + return; + } + // Each one in what is folded away a caret at the end of the line above + // it that shows -- the fold's own line -- as the main one is put; those + // that meet there become one. + size_t main = 0; + std::vector all = selectionsInOrder(&main); + bool moved = false; + for (size_t i = 0; i < all.size(); ++i) + { + const ALTextRange range = all[i].normalised(); + if (i == main || (!layout().hidden(range.begin.line) && !layout().hidden(range.end.line))) + { + continue; + } + const S32 hidden = layout().hidden(range.begin.line) ? range.begin.line : range.end.line; + const S32 above = layout().visibleFrom(hidden, -1); + const S32 below = layout().visibleFrom(hidden, 1); + const ALTextPos to = above >= 0 ? document().lineEnd(above) : ALTextPos(below >= 0 ? below : 0, 0); + all[i] = ALTextRange(to, to); + moved = true; + } + if (moved) + { + placeSelections(all, main); + } +} + bool ALCodeEditor::unfoldAt(S32 line) { if (!folds().unfold(document(), getTabWidth(), line)) @@ -1997,6 +2030,7 @@ void ALCodeEditor::foldAll() { setCaret(document().lineEnd(layout().visibleFrom(caret().line, -1))); } + othersOutOfFolds(); } void ALCodeEditor::unfoldAll() diff --git a/indra/llui/alcodeeditor.h b/indra/llui/alcodeeditor.h index 3d39aee035..2e9cd07e4d 100644 --- a/indra/llui/alcodeeditor.h +++ b/indra/llui/alcodeeditor.h @@ -1077,6 +1077,9 @@ class ALCodeEditor : public ALTextView, public ALVimHost, private ALTextFeatures // Each placeholder's mirrors made what it holds, as one step of its // own. void syncMirrors(S32 index); + // The selections besides the main one that folding has hidden, each a + // caret at the end of the fold's line, as the main one is put. + void othersOutOfFolds(); // The places after `from` that read as `wanted`, going round, whole // names only where asked, none over any of `taken` -- the selections // there are; at most `most` of them. diff --git a/indra/llui/altextview.cpp b/indra/llui/altextview.cpp index b57dd39cfe..e04f0a3b32 100644 --- a/indra/llui/altextview.cpp +++ b/indra/llui/altextview.cpp @@ -918,15 +918,34 @@ void ALTextView::placeSelection(const ALTextPos& anchor, const ALTextPos& caret) mCaret = main.end; } } - if (mLayout.hidden(mCaret.line)) - { + // A caret on a line folded away is not drawn: its line is shown, the + // others' as the main one's, where any line is hidden at all. + const auto reveal = [this](S32 line) { + if (!mLayout.hidden(line)) + { + return; + } if (mFeatures) { - mFeatures->revealLine(mCaret.line); + mFeatures->revealLine(line); } else { - mLayout.setHidden(mCaret.line, mCaret.line, false); + mLayout.setHidden(line, line, false); + } + }; + reveal(mCaret.line); + if (!mCarets.empty() && mLayout.anyHidden()) + { + std::vector lines; + lines.reserve(mCarets.size()); + for (const ALTextRange& other : mCarets) + { + lines.push_back(other.end.line); + } + for (const S32 line : lines) + { + reveal(line); } } mBlink.reset(); diff --git a/indra/llui/tests/alcodeeditor_test.cpp b/indra/llui/tests/alcodeeditor_test.cpp index c56c976742..143a48c425 100644 --- a/indra/llui/tests/alcodeeditor_test.cpp +++ b/indra/llui/tests/alcodeeditor_test.cpp @@ -2689,4 +2689,27 @@ namespace tut e.undo(); ensure_equals("one step", e.text(), std::string("myC\n myC")); } + + template<> template<> + void alcodeeditor_object::test<75>() + { + set_test_name("a caret besides the main one put on a line folded away opens it, as the main one does; folding moves the others out to the fold's line, as it does the main one"); + ALCodeEditor& e = make("default\n{\n state_entry()\n {\n llSay(0, \"a\");\n llSay(0, \"b\");\n }\n}\nx"); + const auto caretAt = [](S32 line, S32 column) { return ALTextRange(ALTextPos(line, column), ALTextPos(line, column)); }; + ensure("folds", e.foldAt(2)); + e.setSelections(caretAt(8, 0), { caretAt(4, 8) }); + ensure("the other caret's line opened", !e.isFolded(2) && !e.layout().hidden(4)); + + e.setSelections(caretAt(8, 0), { caretAt(4, 8), ALTextRange(ALTextPos(5, 8), ALTextPos(5, 13)) }); + ensure("folds again", e.foldAt(2)); + ensure("the main one where it was", e.selection() == caretAt(8, 0)); + ensure("the others out of the fold, at the end of its line, as one", e.otherSelections().size() == 1 && e.otherSelections()[0] == caretAt(2, 17)); + ensure("and the fold kept", e.isFolded(2)); + + e.unfoldAll(); + e.setSelections(caretAt(8, 0), { caretAt(4, 8) }); + e.foldAll(); + ensure("folding all moves them out as well", e.otherSelections().size() == 1 && !e.layout().hidden(e.otherSelections()[0].end.line)); + ensure("into sight above", e.otherSelections()[0] == caretAt(0, 7)); + } } From f56fa88edf8bd3d6b742e63f5909c52848ee4a28 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 18:09:34 -0400 Subject: [PATCH 18/41] The studio's last refills and holds: shared problems, held tabs, includes apart A script's problems are made once and shared: the Problems pane's store takes the rows its tab holds rather than a copy of them, each file's findings held as shared and never changed after (ALFindings). A tab's tip reads where its script is from the skin once, not for every tab at every refill of the strip. Search's Replace All and the Explorer's opening of many rows, or of the scripts a listing brings, hold the tabs as the window's own openings do: the strip, the toolbar and the lists made once, as the last opens. A file a script includes is searched away from the main thread, as a script not open is, and counted as still to come until it is back. Co-Authored-By: Claude Opus 5.5 --- indra/llui/alfindings.h | 28 +++++++++--- indra/llui/tests/alxuifindings_test.cpp | 4 +- indra/newview/alfloaterscriptstudio.cpp | 25 ++++++----- indra/newview/alfloaterscriptstudio.h | 18 ++++++++ indra/newview/alscriptexplorerpane.cpp | 4 +- indra/newview/alscriptproblemspane.cpp | 4 +- indra/newview/alscriptsearchpane.cpp | 45 ++++++++++++++++--- indra/newview/alscriptsearchpane.h | 3 ++ indra/newview/alscriptstudiochecking.cpp | 2 +- indra/newview/alscriptstudiodoc.cpp | 8 ++-- indra/newview/alscriptstudiodoc.h | 9 +++- indra/newview/alscriptstudioservices.h | 16 +++++++ .../tests/alscriptproblemspane_test.cpp | 2 +- .../newview/tests/alscriptsearchpane_test.cpp | 2 + .../tests/alscriptstudiochecking_test.cpp | 7 +-- .../newview/tests/alscriptstudiodoc_test.cpp | 4 +- 16 files changed, 138 insertions(+), 43 deletions(-) diff --git a/indra/llui/alfindings.h b/indra/llui/alfindings.h index eac05d859c..9fc4a7aea5 100644 --- a/indra/llui/alfindings.h +++ b/indra/llui/alfindings.h @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -75,6 +76,10 @@ class ALFindings public: typedef Finding finding_t; typedef ALFindingLevel Level; + // A file's findings as they are held: shared, so that whoever made + // them and keeps them too -- a script's tab -- hands them over without + // a copy, neither changing them after. + typedef std::shared_ptr> shared_t; // Everything one file said, in place of whatever it said before. A file // that said nothing is still a file that was checked, and is kept as one @@ -83,6 +88,10 @@ class ALFindings // rebuild, and its rows should not jump to the end of a list of every // file's each time. void replace(const std::string& file, std::vector found) + { + replace(file, std::make_shared>(std::move(found))); + } + void replace(const std::string& file, shared_t found) { if (file.empty()) { @@ -92,9 +101,13 @@ class ALFindings { mFiles.push_back(file); } + if (!found) + { + found = std::make_shared>(); + } Counts counts; - for (const Finding& finding : found) + for (const Finding& finding : *found) { counts.take(finding, 1); mTotal.take(finding, 1); @@ -163,9 +176,10 @@ class ALFindings { continue; } - for (size_t at = 0; at < held->second.size(); ++at) + const std::vector& found = *held->second; + for (size_t at = 0; at < found.size(); ++at) { - const Finding& finding = held->second[at]; + const Finding& finding = found[at]; switch (Traits::level(finding)) { case Level::Error: if (!query.errors) { continue; } break; @@ -202,7 +216,7 @@ class ALFindings const Finding* at(std::string_view file, size_t index) const { const auto held = mByFile.find(file); - return held != mByFile.end() && index < held->second.size() ? &held->second[index] : nullptr; + return held != mByFile.end() && index < held->second->size() ? &(*held->second)[index] : nullptr; } // The files that have been checked, in the order they first were. @@ -242,7 +256,7 @@ class ALFindings boost::unordered_flat_map> tally; for (const auto& [file, found] : mByFile) { - for (const Finding& finding : found) + for (const Finding& finding : *found) { ++tally[Traits::rule(finding)]; } @@ -265,7 +279,7 @@ class ALFindings { return false; } - for (const Finding& finding : held->second) + for (const Finding& finding : *held->second) { mTotal.take(finding, -1); } @@ -300,7 +314,7 @@ class ALFindings } }; - boost::unordered_flat_map, ll::string_hash, std::equal_to<>> mByFile; + boost::unordered_flat_map> mByFile; boost::unordered_flat_map> mCountByFile; std::vector mFiles; Counts mTotal; diff --git a/indra/llui/tests/alxuifindings_test.cpp b/indra/llui/tests/alxuifindings_test.cpp index 9eea05e857..cfd463fc24 100644 --- a/indra/llui/tests/alxuifindings_test.cpp +++ b/indra/llui/tests/alxuifindings_test.cpp @@ -96,7 +96,7 @@ namespace tut // A file with nothing to say is still a file that was looked at, and // that is not the same answer as one nobody has checked. ensure("one nobody checked", !store.checked("three.xml")); - store.replace("three.xml", {}); + store.replace("three.xml", std::vector()); ensure("one that was", store.checked("three.xml")); ensure_equals("with nothing in it", store.countIn("three.xml"), 0); ensure_equals("and nothing added to the whole", store.size(), 4u); @@ -241,7 +241,7 @@ namespace tut // Let go of and checked again, it is a file checked anew, at the end. store.forget("one.xml"); - store.replace("one.xml", {}); + store.replace("one.xml", std::vector()); ensure("at the end", store.files() == std::vector({ "two.xml", "three.xml", "one.xml" })); ensure_equals("counted once", store.files().size(), size_t(3)); } diff --git a/indra/newview/alfloaterscriptstudio.cpp b/indra/newview/alfloaterscriptstudio.cpp index a5d422f9e3..3907d9008a 100644 --- a/indra/newview/alfloaterscriptstudio.cpp +++ b/indra/newview/alfloaterscriptstudio.cpp @@ -614,6 +614,7 @@ bool ALFloaterScriptStudio::postBuild() // wanted and goes when closed. Said here, since building from the // skin sets both from the file, which cannot say which this is. setIsSingleInstance(mMain); + mTabTips = { getString("TabNotecardTip"), getString("TabInventoryTip"), getString("TabObjectTip"), getString("TabReadOnlyTip") }; if (!mMain) { // No saved rect: a popped-out window is placed beside the one it @@ -3295,14 +3296,14 @@ ALTabStrip::Tab ALFloaterScriptStudio::tabOf(const Doc& doc, const TabFacts& fac // does not fit, and the name it cut is the one thing you hover a // cut tab to read; saying only where the script lives answered a // question nobody had asked. - const std::string where = !doc.file.empty() ? doc.file - : doc.notecard ? getString("TabNotecardTip") - : doc.ref.inInventory() ? getString("TabInventoryTip") - : getString("TabObjectTip"); + const std::string& where = !doc.file.empty() ? doc.file + : doc.notecard ? mTabTips.notecard + : doc.ref.inInventory() ? mTabTips.inventory + : mTabTips.object; tab.toolTip = doc.name + "\n" + where; if (facts.readOnly) { - tab.toolTip += "\n" + getString("TabReadOnlyTip"); + tab.toolTip += "\n" + mTabTips.readOnly; } return tab; } @@ -3900,7 +3901,7 @@ std::vector ALFloaterScriptStudio::problemPlaces(const Doc& doc, bool const std::string definitions = getString("OriginDefinitions"); const std::string weight = getString("OriginWeight"); std::vector places; - for (const Doc::Shown& row : doc.shown) + for (const Doc::Shown& row : doc.shown()) { if (row.file.empty() && row.origin != definitions && row.origin != weight && (!migration || row.migration)) { @@ -4898,11 +4899,11 @@ std::vector ALFloaterScriptStudio::paletteScripts(std::v for (const std::unique_ptr& each : window->mDocs) { const Doc& doc = *each; - const std::string where = !doc.file.empty() ? doc.file - : doc.notecard ? getString("TabNotecardTip") - : doc.ref.inInventory() ? getString("TabInventoryTip") - : !doc.objectName.empty() ? doc.objectName - : getString("TabObjectTip"); + const std::string& where = !doc.file.empty() ? doc.file + : doc.notecard ? mTabTips.notecard + : doc.ref.inInventory() ? mTabTips.inventory + : !doc.objectName.empty() ? doc.objectName + : mTabTips.object; GoTo target; target.kind = GoTo::Kind::Tab; target.window = window->getHandle(); @@ -8339,7 +8340,7 @@ void ALFloaterScriptStudio::addGoCommands() }, [this]() { Doc* doc = active(); - return doc && doc->loaded && !doc->shown.empty(); + return doc && doc->loaded && !doc->shown().empty(); }); } mCommands.add("next_tab", [this]() { cycleTab(1); }); diff --git a/indra/newview/alfloaterscriptstudio.h b/indra/newview/alfloaterscriptstudio.h index 78e415afad..91e3ef5a20 100644 --- a/indra/newview/alfloaterscriptstudio.h +++ b/indra/newview/alfloaterscriptstudio.h @@ -170,6 +170,14 @@ class ALFloaterScriptStudio final : public ALStudioFloater, public ALScriptStudi // left where it is. void openScript(const ALScriptRef& ref, const std::string& name, std::optional carried = std::nullopt, S32 line = -1, bool focus = true) override; + void holdTabs() override { ++mTabsHeld; } + void letGoOfTabs() override + { + if (mTabsHeld > 0 && --mTabsHeld == 0) + { + releaseTabs(); + } + } // The active script moved to a window of its own, its unsaved text // going with it; or to another studio window already open. False where // it could not go. @@ -991,6 +999,16 @@ class ALFloaterScriptStudio final : public ALStudioFloater, public ALScriptStudi std::optional mToolbarFacts; std::vector mTabFacts; size_t mTabFactsActive = NONE; + // What a tab's tip says of where its script is, read from the skin + // once rather than for every tab at every refill. + struct TabTips + { + std::string notecard; + std::string inventory; + std::string object; + std::string readOnly; + }; + TabTips mTabTips; // What changed of a tab that the window shows of it, made good once // before the next frame is drawn, however often it was asked for in // between (docChanged); and made good now, all of it or one tab's. diff --git a/indra/newview/alscriptexplorerpane.cpp b/indra/newview/alscriptexplorerpane.cpp index 1063b0e726..fba051192d 100644 --- a/indra/newview/alscriptexplorerpane.cpp +++ b/indra/newview/alscriptexplorerpane.cpp @@ -441,6 +441,7 @@ void ALScriptExplorerPane::contentsHeard(const ALScriptContents& contents) mWindow->explorerPinsChanged(); } mStale = true; + ALScriptStudioServices::TabsHeld held(*mServices); for (Model::Opening& one : heard.opening) { mServices->openScript(one.ref, one.name, std::move(one.text)); @@ -676,7 +677,8 @@ void ALScriptExplorerPane::onChosen() { // A script or notecard opened; an object, a prim or a linkset's row of // prims holding nothing opened where it is folded, folded where it is - // open. + // open. The tabs made once, as the last is opened. + ALScriptStudioServices::TabsHeld held(*mServices); for (const LLSD& value : mTree->chosen()) { const std::optional row = Choice::of(value); diff --git a/indra/newview/alscriptproblemspane.cpp b/indra/newview/alscriptproblemspane.cpp index 73dbcfee15..deacb98b98 100644 --- a/indra/newview/alscriptproblemspane.cpp +++ b/indra/newview/alscriptproblemspane.cpp @@ -352,7 +352,7 @@ bool ALScriptProblemsPane::postBuild() void ALScriptProblemsPane::changed(const Doc& doc) { - mStore.replace(doc.id, doc.shown); + mStore.replace(doc.id, doc.shownRows); // The list, where it lists this script's: the one it is about, or // every one open. Doc* shown = listed(); @@ -672,7 +672,7 @@ void ALScriptProblemsPane::fill(const Doc* doc) }; for (const Doc* each : docs) { - count(each->shown); + count(each->shown()); } for (const Checked* each : checked) { diff --git a/indra/newview/alscriptsearchpane.cpp b/indra/newview/alscriptsearchpane.cpp index d563068cff..176d27d119 100644 --- a/indra/newview/alscriptsearchpane.cpp +++ b/indra/newview/alscriptsearchpane.cpp @@ -250,16 +250,45 @@ void ALScriptSearchPane::searchIncludes(const ALScriptRef& ref, const std::strin } for (const Window::Included& one : mWindow->includesOf(ref, file, name, text, lua)) { - if (mIncludesSearched.insert(one.path).second) + if (!mIncludesSearched.insert(one.path).second) { - const ALScriptSearch::Kept kept = mSearch.search(ALScriptRef(), one.name, mServices->words("SearchIncluded"), ALTextDocument(one.text), 0, - std::string(), false, false, one.path); - if (kept == ALScriptSearch::Kept::Added) - { - addRows(mSearch.found().size() - 1); - } + continue; } + // Sought away from the main thread, as a script not open is, and + // counted as to come until the answer is back: an include can be a + // library many times the script's size. + mSearch.asked(); + const U32 generation = mSearch.generation(); + const LLHandle handle = getHandle(); + const std::string where = mServices->words("SearchIncluded"); + mWindow->matchApart(std::make_shared(one.text), mSearch.query(), mSearch.options(), + [handle, generation, where, name = one.name, path = one.path](ALScriptSearch::Matched found) { + if (ALScriptSearchPane* pane = ALViewType::as(handle.get())) + { + pane->includeMatched(generation, where, name, path, std::move(found)); + } + }); + } +} + +void ALScriptSearchPane::includeMatched(U32 generation, const std::string& where, const std::string& name, const std::string& path, + ALScriptSearch::Matched found) +{ + if (!mSearch.answered(generation)) + { + return; } + // Kept as its own, without its text: Replace All leaves an include be. + const ALScriptSearch::Kept kept = mSearch.keep(ALScriptRef(), name, where, std::move(found), 0, std::string(), nullptr, false, path); + if (mSearch.badPattern()) + { + mCount->setToolTip(mSearch.patternError()); + } + else if (kept == ALScriptSearch::Kept::Added) + { + addRows(mSearch.found().size() - 1); + } + settled(); } void ALScriptSearchPane::run() @@ -771,6 +800,8 @@ void ALScriptSearchPane::replaceAll() // since the search is left alone, and said so. const std::vector found = mSearch.found(); S32 places = 0, scripts = 0, opened = 0, left = 0; + // The tabs it opens made once, as the last is opened. + ALScriptStudioServices::TabsHeld held(*mServices); for (const ALScriptSearch::Found& one : found) { Doc* here = tabOf(one); diff --git a/indra/newview/alscriptsearchpane.h b/indra/newview/alscriptsearchpane.h index bd0dfa01f7..6b5380ce06 100644 --- a/indra/newview/alscriptsearchpane.h +++ b/indra/newview/alscriptsearchpane.h @@ -173,6 +173,9 @@ class ALScriptSearchPane : public LLPanel // this search's. void matched(U32 generation, const std::string& where, const ALScriptRef& ref, const std::string& name, const std::string& text, bool notecard, ALScriptSearch::Matched found); + // What an include held of the words, back from away from the main + // thread. + void includeMatched(U32 generation, const std::string& where, const std::string& name, const std::string& path, ALScriptSearch::Matched found); void buildSentence(); void onChanged(); void searchOpen(const Doc& doc); diff --git a/indra/newview/alscriptstudiochecking.cpp b/indra/newview/alscriptstudiochecking.cpp index 1c79e8f2e5..a6c0841e57 100644 --- a/indra/newview/alscriptstudiochecking.cpp +++ b/indra/newview/alscriptstudiochecking.cpp @@ -1637,7 +1637,7 @@ void ALScriptStudioChecking::fixesOn(Doc& doc, S32 line, std::vectordocument().version(); - for (const Doc::Shown& shown : doc.shown) + for (const Doc::Shown& shown : doc.shown()) { if (!shown.file.empty() || shown.line != line || shown.fixesFor != now) { diff --git a/indra/newview/alscriptstudiodoc.cpp b/indra/newview/alscriptstudiodoc.cpp index 5826c1c70a..d543daedd5 100644 --- a/indra/newview/alscriptstudiodoc.cpp +++ b/indra/newview/alscriptstudiodoc.cpp @@ -351,11 +351,11 @@ std::string ALScriptStudioDoc::foundAs(const std::string& name, std::optional rows) { - shown = std::move(rows); + shownRows = std::make_shared>(std::move(rows)); shownErrors = 0; shownWarnings = 0; shownMigration = 0; - for (const Shown& one : shown) + for (const Shown& one : *shownRows) { shownErrors += one.level == Level::Error ? 1 : 0; shownWarnings += one.level == Level::Warning ? 1 : 0; @@ -365,7 +365,7 @@ void ALScriptStudioDoc::setShown(std::vector rows) const ALScriptStudioDoc::Shown* ALScriptStudioDoc::findShown(S32 line, S32 column, const std::string& file, const std::string& message) const { - for (const Shown& one : shown) + for (const Shown& one : shown()) { if (one.line == line && one.column == column && one.file == file && one.message == message) { @@ -380,7 +380,7 @@ std::vector ALScriptStudioDoc::pickFixes(const FixPick& pick std::vector taken; const U32 now = editor->document().version(); const bool only_safe = pick.forSave || (pick.key.empty() && !pick.migration); - for (const Shown& one : shown) + for (const Shown& one : shown()) { if (!one.file.empty() || one.fixesFor != now || (!pick.key.empty() && one.key != pick.key) || (pick.migration && !one.migration)) { diff --git a/indra/newview/alscriptstudiodoc.h b/indra/newview/alscriptstudiodoc.h index 6309342e97..586ca7c0d5 100644 --- a/indra/newview/alscriptstudiodoc.h +++ b/indra/newview/alscriptstudiodoc.h @@ -501,7 +501,14 @@ struct ALScriptStudioDoc // (ALScriptLintPass::migration). bool migration = false; }; - std::vector shown; + // The rows, shared with the Problems pane's store, which holds them + // as they are without a copy; never changed once put, only put again. + std::shared_ptr> shownRows; + const std::vector& shown() const + { + static const std::vector none; + return shownRows ? *shownRows : none; + } // How many of them are errors and warnings, counted as they are put // in: the tab's dot and the trailer ask on every key. S32 shownErrors = 0; diff --git a/indra/newview/alscriptstudioservices.h b/indra/newview/alscriptstudioservices.h index 069a985ae8..6b1415833d 100644 --- a/indra/newview/alscriptstudioservices.h +++ b/indra/newview/alscriptstudioservices.h @@ -107,6 +107,22 @@ class ALScriptStudioServices bool focus = true) = 0; // A place in a script, gone to once the script is open, or now. virtual void goToPlace(const ALScriptRef& ref, const std::string& name, S32 line, S32 column, S32 length) = 0; + // Many tabs opened at once: while held, what each would make again -- + // the strip, the toolbar, the lists -- waits, and is made once as the + // last lets go (Held below). Nothing where tabs make nothing. + virtual void holdTabs() {} + virtual void letGoOfTabs() {} + class TabsHeld + { + public: + explicit TabsHeld(ALScriptStudioServices& services) : mServices(services) { mServices.holdTabs(); } + ~TabsHeld() { mServices.letGoOfTabs(); } + TabsHeld(const TabsHeld&) = delete; + TabsHeld& operator=(const TabsHeld&) = delete; + + private: + ALScriptStudioServices& mServices; + }; // A list's row chosen and its place shown: the keyboard left in the // list to walk on, or, `to_editor`, taken to the script to type there. virtual void revealed(LLUICtrl* list, bool to_editor) = 0; diff --git a/indra/newview/tests/alscriptproblemspane_test.cpp b/indra/newview/tests/alscriptproblemspane_test.cpp index be88801d9e..bb7552b7c0 100644 --- a/indra/newview/tests/alscriptproblemspane_test.cpp +++ b/indra/newview/tests/alscriptproblemspane_test.cpp @@ -190,7 +190,7 @@ namespace tut making.target = ALScriptWeight::Target::Mono; making.includeName = [](const std::string& path) { return path.substr(path.rfind('/') + 1); }; ALScriptProblemsPane::Made made = ALScriptProblemsPane::make(d, services, making); - d.shown = made.rows; + d.setShown(made.rows); if (pane) { pane->changed(d); diff --git a/indra/newview/tests/alscriptsearchpane_test.cpp b/indra/newview/tests/alscriptsearchpane_test.cpp index 7f777fd272..ce393de489 100644 --- a/indra/newview/tests/alscriptsearchpane_test.cpp +++ b/indra/newview/tests/alscriptsearchpane_test.cpp @@ -435,8 +435,10 @@ namespace tut ensure_equals("alone: the script's own", out.search().found().size(), size_t(1)); ensure("nothing asked of what it includes", studio.askedIncludes.empty()); out.bar()->setValue("includes", "yes"); + const S32 before = studio.apart; find("timer"); ensure_equals("and the file it includes, once for the two", out.search().found().size(), size_t(2)); + ensure("sought apart, once, and back", studio.apart == before + 1 && out.search().pending() == 0); const ALScriptSearch::Found& found = out.search().found()[1]; ensure_equals("by its identity", found.file, std::string("disk:/scripts/lib.lsl")); ensure("said to be included", found.where.find(services.words("SearchIncluded")) != std::string::npos && found.where.find("lib.lsl") != std::string::npos); diff --git a/indra/newview/tests/alscriptstudiochecking_test.cpp b/indra/newview/tests/alscriptstudiochecking_test.cpp index 9058faeaf0..9d216ea6e6 100644 --- a/indra/newview/tests/alscriptstudiochecking_test.cpp +++ b/indra/newview/tests/alscriptstudiochecking_test.cpp @@ -153,7 +153,7 @@ namespace { return; } - doc.shown.clear(); + std::vector rows; for (const ALScriptProblem& problem : doc.check->analysis) { Doc::Shown shown; @@ -166,8 +166,9 @@ namespace shown.fixesFor = doc.check->analysisVersion; // As the pane's rows have it (ALScriptStudioDoc::analysisRow). shown.migration = doc.language.lua && ALScriptLintPass::migration(problem); - doc.shown.push_back(std::move(shown)); + rows.push_back(std::move(shown)); } + doc.setShown(std::move(rows)); } void showOutline(Doc& doc) override { told.push_back("outline " + doc.id); } void save(Doc& doc) override { told.push_back("save " + doc.id); } @@ -847,7 +848,7 @@ namespace tut one.fixes = { fix("First", 0, 0, 7, "float") }; checking.ask(doc, Kind::Check, ALTextPos(), ALTextPos()); studio.asks[0].answered(answer(doc, { one, problem(2, "two") })); - ensure("asked for, made with the next frame", studio.waiting.count("a") == 1 && doc.shown.empty()); + ensure("asked for, made with the next frame", studio.waiting.count("a") == 1 && doc.shown().empty()); std::vector fixes; checking.fixesOn(doc, 0, fixes); ensure("made first: one", studio.waiting.empty() && fixes.size() == 1 && fixes[0].title == "First" && diff --git a/indra/newview/tests/alscriptstudiodoc_test.cpp b/indra/newview/tests/alscriptstudiodoc_test.cpp index 4bc4bd15e7..1563a5bf88 100644 --- a/indra/newview/tests/alscriptstudiodoc_test.cpp +++ b/indra/newview/tests/alscriptstudiodoc_test.cpp @@ -262,11 +262,11 @@ namespace tut rows[2].level = Doc::Level::Warning; rows[3].level = Doc::Level::Note; doc.setShown(rows); - ensure("four rows", doc.shown.size() == 4); + ensure("four rows", doc.shown().size() == 4); ensure_equals("one error", doc.shownErrors, 1); ensure_equals("two warnings", doc.shownWarnings, 2); doc.setShown({}); - ensure("none", doc.shown.empty() && doc.shownErrors == 0 && doc.shownWarnings == 0); + ensure("none", doc.shown().empty() && doc.shownErrors == 0 && doc.shownWarnings == 0); } template<> template<> From aea515eddc5d4a09675673dbb14861c97565b904 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 18:10:48 -0400 Subject: [PATCH 19/41] LSL to SLua: a parameter only read as a truth is a boolean A function's integer parameter the script reads only as a truth -- in an if, behind !, with && and || -- is a boolean in the SLua written, as the script's own variables already were: typed so where types are written, and each call giving it a truth, a number turned into one. One read as a number, or given more than TRUE where it is asked about TRUE, stays a number. An event's parameters stay the numbers the grid's signatures give them. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/allsltoslua.cpp | 37 ++++++++++++++++++++--- indra/alscript/tests/allsltoslua_test.cpp | 37 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/indra/alscript/allsltoslua.cpp b/indra/alscript/allsltoslua.cpp index c641f4fd03..2b58116f04 100644 --- a/indra/alscript/allsltoslua.cpp +++ b/indra/alscript/allsltoslua.cpp @@ -953,13 +953,15 @@ namespace int at = 0; for (LSLASTNode* arg = isNull(list) ? nullptr : list->getChild(0); arg; arg = arg->getNext(), ++at) { - auto* given = static_cast(arg); - LSLIType to = param ? varType(static_cast(param)->getSymbol(), param->getIType()) : given->getIType(); + auto* given = static_cast(arg); + LSLSymbol* taken = param ? static_cast(param)->getSymbol() : nullptr; + LSLIType to = param ? varType(taken, param->getIType()) : given->getIType(); if ((text & (1 << at)) && (slType(given) == LST_STRING || slType(given) == LST_KEY)) { to = slType(given); } - out += (out.empty() ? "" : ", ") + coerced(given, to).text; + // A parameter the script only reads as a truth is given one. + out += (out.empty() ? "" : ", ") + (boolean(taken) ? truthOf(given) : coerced(given, to).text); param = param ? param->getNext() : nullptr; } return out; @@ -2860,6 +2862,30 @@ namespace { mBooleans.insert(f->getSymbol()); } + // Its integer parameters, each set by what its calls give + // it. An event's are the grid's, typed as numbers there, + // and stay numbers. + for (LSLASTNode* param = f->getArguments() ? f->getArguments()->getChild(0) : nullptr; !isNull(param); param = param->getNext()) + { + candidate(static_cast(param), nullptr); + } + } + else if (node->getNodeSubType() == NODE_FUNCTION_EXPRESSION) + { + // A call of the script's own function: each argument what + // its parameter is set to. + auto* call = static_cast(node); + LSLSymbol* symbol = call->getIdentifier()->getSymbol(); + LSLParamList* params = symbol && symbol->getSubType() != SYM_BUILTIN ? symbol->getFunctionDecl() : nullptr; + LSLASTNode* param = params ? params->getChild(0) : nullptr; + for (LSLASTNode* arg = isNull(call->getArguments()) ? nullptr : call->getArguments()->getChild(0); arg && !isNull(param); + arg = arg->getNext(), param = param->getNext()) + { + if (LSLSymbol* set = static_cast(param)->getSymbol(); set && param->getIType() == LST_INTEGER) + { + sets.emplace_back(set, static_cast(arg)); + } + } } else if (node->getNodeSubType() == NODE_DECLARATION) { @@ -2874,7 +2900,7 @@ namespace sets.emplace_back(f, e); } } - else if ((node->getNodeSubType() == NODE_LVALUE_EXPRESSION || node->getNodeSubType() == NODE_FUNCTION_EXPRESSION) && readOf(node)) + if ((node->getNodeSubType() == NODE_LVALUE_EXPRESSION || node->getNodeSubType() == NODE_FUNCTION_EXPRESSION) && readOf(node)) { reads.push_back(node); } @@ -4271,8 +4297,9 @@ namespace std::string params; for (LSLASTNode* p = f->getArguments() ? f->getArguments()->getChild(0) : nullptr; p; p = p->getNext()) { + LSLSymbol* param = static_cast(p)->getSymbol(); params += (params.empty() ? "" : ", ") + nameOf(static_cast(p)) + - typed(varType(static_cast(p)->getSymbol(), p->getIType())); + (boolean(param) ? std::string(mOptions.types ? ": boolean" : "") : typed(varType(param, p->getIType()))); } mFunction = f->getSymbol(); line(std::string(forward ? "function " : "local function ") + nameOf(f->getIdentifier()) + "(" + params + ")" + diff --git a/indra/alscript/tests/allsltoslua_test.cpp b/indra/alscript/tests/allsltoslua_test.cpp index 822cf3d457..2f64cf853c 100644 --- a/indra/alscript/tests/allsltoslua_test.cpp +++ b/indra/alscript/tests/allsltoslua_test.cpp @@ -1008,4 +1008,41 @@ namespace tut ensure("read in the studio's words", linted(ALLSLToSLua::notesIn(said.text, close.words)) == 1); ensure("not in English's", linted(ALLSLToSLua::notesIn(said.text, nullptr)) == 0); } + + template<> template<> + void allsltoslua_object::test<33>() + { + set_test_name("a parameter the script only reads as a truth is a boolean, each call giving one; one read as a number, or given more than TRUE where it is asked about TRUE, stays a number; an event's stay the grid's numbers"); + const ALLSLToSLua::Result r = convert("say(integer loud, string text) { if (loud) llShout(0, text); else llSay(0, text); }\n" + "glow(integer on) { if (!on) return; llOwnerSay(\"glow\"); }\n" + "integer next(integer n) { return n + 1; }\n" + "integer exactly(integer f) { return f == TRUE; }\n" + "default {\n" + " touch_start(integer n) {\n" + " say(TRUE, \"hi\");\n" + " say(n > 1, \"many\");\n" + " say(n, \"some\");\n" + " glow(FALSE);\n" + " llOwnerSay((string)next(n));\n" + " exactly(1); exactly(5);\n" + " }\n" + " on_rez(integer p) { if (p) llOwnerSay(\"rezzed\"); }\n" + "}\n"); + ensure("read only as a truth: a boolean: " + r.text, has(r, "local function say(loud, text)") && has(r, "if loud then")); + ensure("each call giving one, a number as its truth: " + r.text, + has(r, "say(true, \"hi\")") && has(r, "say(n > 1, \"many\")") && has(r, "say(n ~= 0, \"some\")")); + ensure("not, as a truth: " + r.text, has(r, "if not on then") && has(r, "glow(false)")); + ensure("read as a number: kept one: " + r.text, has(r, "return n + 1") && has(r, "next_(n)")); + ensure("given 5 where asked about TRUE: kept a number: " + r.text, has(r, "f == 1") && has(r, "exactly(5)")); + ensure("an event's: the grid's number: " + r.text, has(r, "if p ~= 0 then")); + checksClean(r); + + ALLSLToSLua::Options typed; + typed.types = true; + const ALLSLToSLua::Result t = ALLSLToSLua::convert("say(integer loud, string text) { if (loud) llSay(0, text); }\n" + "default { state_entry() { say(TRUE, \"hi\"); } }\n", + typed); + ensure("typed: " + t.text, has(t, "local function say(loud: boolean, text: string)")); + checksClean(t); + } } From 0ff8d8032205ad46e18b6259d4bb566faad75543 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 18:12:48 -0400 Subject: [PATCH 20/41] LSL to SLua: a string built in a loop is declared where it is joined A string declared empty just before the loop that builds it, in the same block, with nothing between reading or setting it, is declared where its pieces are joined -- local row = table.concat(rowParts) -- rather than declared empty and then added to. One declared with words of its own, or read before the loop, is added to as before. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/allsltoslua.cpp | 65 +++++++++++++++++++++-- indra/alscript/tests/allsltoslua_test.cpp | 12 +++-- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/indra/alscript/allsltoslua.cpp b/indra/alscript/allsltoslua.cpp index 2b58116f04..b1ee4634c7 100644 --- a/indra/alscript/allsltoslua.cpp +++ b/indra/alscript/allsltoslua.cpp @@ -651,6 +651,11 @@ namespace // The strings each loop builds, and those being built, by the // table of their pieces. boost::unordered_flat_map> mBuilds; + // A string a loop builds, declared empty in the loop's own block + // with nothing between that touches it: declared where its pieces + // are joined instead, its declaration not written. + boost::unordered_flat_set mJoinedDeclarations; + boost::unordered_flat_set mDeclaredAtJoin; boost::unordered_flat_map mBuilding; // Appends in loops that are noted rather than built. boost::unordered_flat_set mUnbuilt; @@ -2157,6 +2162,11 @@ namespace { return; } + // A string declared where the loop building it joins it. + if (mJoinedDeclarations.contains(d)) + { + return; + } const Steps steps = isNull(init) ? Steps() : hoistSteps(init, true); writeSteps(steps.before); if (boolean(id->getSymbol())) @@ -3687,6 +3697,46 @@ namespace } } }); + // Declared empty just before the loop that builds it, in the same + // block, nothing between reading or setting it: declared at the join. + const auto touches = [](LSLASTNode* statement, LSLSymbol* var) { + bool seen = false; + walk(statement, [&](LSLASTNode* inner) { + seen = seen || (inner->getNodeSubType() == NODE_LVALUE_EXPRESSION && + static_cast(inner)->getIdentifier()->getSymbol() == var); + }); + return seen; + }; + for (const auto& [built_by, vars] : mBuilds) + { + LSLASTNode* block = built_by->getParent(); + if (!block || block->getNodeSubType() != NODE_COMPOUND_STATEMENT) + { + continue; + } + for (LSLSymbol* var : vars) + { + LSLDeclaration* declared = nullptr; + bool touched = false; + for (LSLASTNode* statement = block->getChild(0); statement && statement != built_by; statement = statement->getNext()) + { + if (statement->getNodeSubType() == NODE_DECLARATION && static_cast(statement)->getIdentifier()->getSymbol() == var) + { + declared = static_cast(statement); + touched = false; + } + else if (declared && touches(statement, var)) + { + touched = true; + } + } + if (declared && !touched && (isNull(declared->getInitializer()) || emptyValue(declared->getInitializer()))) + { + mJoinedDeclarations.insert(declared); + mDeclaredAtJoin.insert(var); + } + } + } // An append an outer loop builds after all is not noted. boost::unordered::erase_if(mUnbuilt, [&](LSLASTNode* append) { LSLSymbol* var = nullptr; @@ -3705,23 +3755,30 @@ namespace { const std::vector vars = std::move(mBuilds[loop]); mBuilds.erase(loop); - std::vector> joins; + struct Join + { + std::string name; + std::string parts; + bool declared = false; + }; + std::vector joins; for (LSLSymbol* var : vars) { const std::string name = mNames.contains(var) ? mNames[var] : nameOf(var->getName()); const std::string parts = freshName(name + "Parts"); line("local " + parts + (mOptions.types ? ": { string }" : "") + " = {}"); mBuilding[var] = parts; - joins.emplace_back(name, parts); + joins.push_back({ name, parts, mDeclaredAtJoin.contains(var) }); } statement(loop, last); for (LSLSymbol* var : vars) { mBuilding.erase(var); } - for (const auto& [name, parts] : joins) + for (const Join& join : joins) { - line(name + " ..= table.concat(" + parts + ")"); + line(join.declared ? "local " + join.name + (mOptions.types ? ": string" : "") + " = table.concat(" + join.parts + ")" + : join.name + " ..= table.concat(" + join.parts + ")"); } } diff --git a/indra/alscript/tests/allsltoslua_test.cpp b/indra/alscript/tests/allsltoslua_test.cpp index 2f64cf853c..c1b5f8309b 100644 --- a/indra/alscript/tests/allsltoslua_test.cpp +++ b/indra/alscript/tests/allsltoslua_test.cpp @@ -554,8 +554,10 @@ namespace tut ensure("the pieces put in: " + r.text, has(r, "table.insert(outParts2, names[i] .. row)")); ensure("joined after: " + r.text, has(r, " end\n out ..= table.concat(outParts2)")); ensure("the inner loop's, declared in the outer: " + r.text, - has(r, " local rowParts = {}\n for j = 0, 2 do\n table.insert(rowParts, tostring(j))") && - has(r, " row ..= table.concat(rowParts)")); + has(r, " local rowParts = {}\n for j = 0, 2 do\n table.insert(rowParts, tostring(j))")); + ensure("declared empty just before it: declared where it is joined: " + r.text, + has(r, " end\n local row = table.concat(rowParts)") && !has(r, "local row = \"\"")); + ensure("declared with words of its own: added to: " + r.text, has(r, "local out = \"Names: \"") && has(r, "out ..= table.concat(outParts2)")); ensure("read in its loop: noted: " + r.text, has(r, "seen ..= \"x\"") && has(r, "-- LSL: seen is built with .. in a loop")); ensure("a global: noted: " + r.text, has(r, "gReport ..= \"y\"") && has(r, "-- LSL: gReport is built with .. in a loop")); checksClean(r); @@ -564,8 +566,12 @@ namespace tut typed.types = true; const ALLSLToSLua::Result t = ALLSLToSLua::convert("default { state_entry() { string s; integer i; for (i = 0; i < 3; ++i) s += \"z\"; llOwnerSay(s); } }\n", typed); - ensure("typed: " + t.text, has(t, "local sParts: { string } = {}")); + ensure("typed: " + t.text, has(t, "local sParts: { string } = {}") && has(t, "local s: string = table.concat(sParts)")); checksClean(t); + + const ALLSLToSLua::Result read = convert("default { state_entry() { string t = \"\"; llOwnerSay(t); integer i; for (i = 0; i < 3; ++i) t += \"q\"; llOwnerSay(t); } }\n"); + ensure("read between: declared where it was: " + read.text, has(read, "local t = \"\"") && has(read, "t ..= table.concat(tParts)")); + checksClean(read); } template<> template<> From ff2574bbb75f8c166de22810bc50f91cde06d23e Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 18:18:01 -0400 Subject: [PATCH 21/41] A global first set inside a function, and named across the script, is a note SlGlobalAssign said a global made at the top of the script, and a function made global in a block, but not a global first set inside a function: `count = 0` in init() and `count += 1` in a handler. Where more than one statement of the top names it, it is a note now, fixed by a local of the script's declared before the first of them, which every function there still shares. Where one function alone names it, Luau's own GlobalUsedAsLocal says so, and this says nothing. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/alscriptlintpass.cpp | 115 ++++++++++++++++++ .../alscript/tests/alscriptlintpass_test.cpp | 31 +++++ .../newview/skins/default/xui/en/strings.xml | 1 + 3 files changed, 147 insertions(+) diff --git a/indra/alscript/alscriptlintpass.cpp b/indra/alscript/alscriptlintpass.cpp index 127d7d7d67..55b34702d6 100644 --- a/indra/alscript/alscriptlintpass.cpp +++ b/indra/alscript/alscriptlintpass.cpp @@ -486,6 +486,57 @@ namespace } }; + // Every global the script sets, by name: where it is first set, and + // whether it is ever set by a statement of the script's top itself. + class GlobalSets final : public Luau::AstVisitor + { + public: + struct Set + { + Luau::Location first; + bool atTop = false; + }; + explicit GlobalSets(const Luau::AstStatBlock* root) + { + for (Luau::AstStat* stat : root->body) + { + mTop.insert(stat); + } + } + boost::unordered_flat_map sets; + + bool visit(Luau::AstStatAssign* node) override + { + for (Luau::AstExpr* var : node->vars) + { + took(var, node); + } + return true; + } + bool visit(Luau::AstStatCompoundAssign* node) override + { + took(node->var, node); + return true; + } + + private: + void took(Luau::AstExpr* var, Luau::AstStat* stat) + { + const auto* global = var->as(); + if (!global) + { + return; + } + const auto [it, added] = sets.try_emplace(global->name.value, Set{ global->location, false }); + if (!added && global->location.begin < it->second.first.begin) + { + it->second.first = global->location; + } + it->second.atTop = it->second.atTop || mTop.contains(stat); + } + boost::unordered_flat_set mTop; + }; + // What a loop's body does to strings, not looking into the functions // it makes: each append, s ..= x or s = s .. x, what it appends and to // what; every read of each local; and whether it returns. @@ -658,6 +709,10 @@ namespace nestedFunction(node, stat); } } + if (node == mRoot && on("SlGlobalAssign")) + { + globalsSetInFunctions(); + } } if (on("SlAlmostSwapped")) { @@ -2117,6 +2172,66 @@ namespace } } + // A global the script first sets inside a function, never at its top, + // and names in more than one of its top's statements: a note, fixed + // by a local of the script's before the first of them, which every + // function there still shares. Where one function alone names it, + // Luau's own GlobalUsedAsLocal says so. + void globalsSetInFunctions() + { + if (mGlobals.viaG) + { + return; + } + GlobalSets sets(mRoot); + const_cast(mRoot)->visit(&sets); + std::vector> ordered(sets.sets.begin(), sets.sets.end()); + std::sort(ordered.begin(), ordered.end(), [](const auto& a, const auto& b) { return a.second.first.begin < b.second.first.begin; }); + for (const auto& [name, set] : ordered) + { + if (set.atTop || mTopMade.contains(name) || mGiven.contains(name) || name == "_G") + { + continue; + } + const auto named_at = mGlobals.named.find(name); + if (named_at == mGlobals.named.end()) + { + continue; + } + // The statements of the top that name it, in order. + std::vector tops; + for (const Luau::Location& at : named_at->second) + { + for (Luau::AstStat* top : mRoot->body) + { + if (top->location.encloses(at)) + { + if (tops.empty() || tops.back() != top) + { + tops.push_back(top); + } + break; + } + } + } + if (tops.size() < 2) + { + continue; + } + ALScriptProblem& said = problem(set.first, "LuauLintSlGlobalInFunction", + "[1] is made a global inside a function. A local of the script's, declared before the first " + "function that names it, is shared the same and quicker for Luau to read", + { name }, "SlGlobalAssign", Severity::Note); + if (startsLine(tops.front()->location.begin)) + { + const std::string indent(tops.front()->location.begin.column, ' '); + offerTitled(said, "ScriptFixDeclareFirst", "Declare local [1] before it is first named", { name }, + { edit(Luau::Location(tops.front()->location.begin, tops.front()->location.begin), "local " + name + "\n" + indent) }, + false); + } + } + } + // function f() in a block of its own, which makes a global only when // the block runs: a local of the block's where the block alone names // it, after it. diff --git a/indra/alscript/tests/alscriptlintpass_test.cpp b/indra/alscript/tests/alscriptlintpass_test.cpp index a9634aa8dd..e337bfd2c7 100644 --- a/indra/alscript/tests/alscriptlintpass_test.cpp +++ b/indra/alscript/tests/alscriptlintpass_test.cpp @@ -321,6 +321,7 @@ namespace tut const std::string said = found(script, "SlGlobalAssign", ALScriptProblem::Severity::Warning); ensure_equals("each", said, std::string("0 LuauLintSlGlobalAssign|count (another severity)\n" + "1 LuauLintSlGlobalInFunction|total (another severity)\n" "3 LuauLintSlGlobalAssign|later (another severity)\n" "6 LuauLintSlGlobalFunctionInScope|inner\n" "7 LuauLintSlGlobalFunctionInScope|shared\n" @@ -329,6 +330,7 @@ namespace tut std::string("1 LuauLintSlGlobalFunction|bump\n")); ensure_equals("off on its own: the rest still said", found("--!nolint SlGlobalFunction\n" + script, "SlGlobalAssign", ALScriptProblem::Severity::Warning), std::string("1 LuauLintSlGlobalAssign|count (another severity)\n" + "2 LuauLintSlGlobalInFunction|total (another severity)\n" "4 LuauLintSlGlobalAssign|later (another severity)\n" "7 LuauLintSlGlobalFunctionInScope|inner\n" "8 LuauLintSlGlobalFunctionInScope|shared\n" @@ -872,4 +874,33 @@ namespace tut "8 LuauLintSlForIndexAssign|i\n")); unfixed("for i = 1, 3 do i = 2 end\n", "LuauLintSlForIndexAssign"); } + + template<> template<> + void object::test<23>() + { + set_test_name("SlGlobalAssign: a global first set inside a function and named by more than one statement of the top, a note, " + "declared local before the first; one function alone naming it left to Luau's GlobalUsedAsLocal; one set at the top " + "said as before"); + ensure("definitions: " + error, loaded); + const std::string shared = "function init()\n" + " count = 0\n" + "end\n" + "LLEvents:on(\"touch_start\", function(detected)\n" + " count += 1\n" + " print(count)\n" + "end)\n" + "init()\n"; + ensure_equals("set in one, named in another", found(shared, "SlGlobalAssign", ALScriptProblem::Severity::Note), + std::string("1 LuauLintSlGlobalInFunction|count\n")); + ensure_equals("declared before the first that names it", fixed(shared, "LuauLintSlGlobalInFunction", "Declare local count before it is first named", false), + std::string("local count\n") + shared); + ensure("one function alone: Luau's", + found("LLEvents:on(\"touch_start\", function(detected)\n total = 1\n print(total)\nend)\n", "SlGlobalAssign", ALScriptProblem::Severity::Note) + .find("LuauLintSlGlobalInFunction") == std::string::npos); + ensure("set at the top too: said there, not here", + found("count = 0\nfunction bump() count += 1 end\nbump()\n", "SlGlobalAssign", ALScriptProblem::Severity::Note).find("LuauLintSlGlobalInFunction") == + std::string::npos); + ensure("not where _G is", + found(std::string("print(_G)\n") + shared, "SlGlobalAssign", ALScriptProblem::Severity::Note).find("LuauLintSlGlobalInFunction") == std::string::npos); + } } diff --git a/indra/newview/skins/default/xui/en/strings.xml b/indra/newview/skins/default/xui/en/strings.xml index 9b0e88b713..bb9b67635f 100644 --- a/indra/newview/skins/default/xui/en/strings.xml +++ b/indra/newview/skins/default/xui/en/strings.xml @@ -900,6 +900,7 @@ If you feel this is an error, please contact support@secondlife.com This makes [1] a global. A local is quicker for Luau to read function [1] makes a global. local function [1] is quicker for Luau to call [1] is set here, but the for gives [1] its own next value each time round, whatever it was set to: LSL's for went on from it. A while loop does + [1] is made a global inside a function. A local of the script's, declared before the first function that names it, is shared the same and quicker for Luau to read function [1] here makes a global, and only once this runs. local function [1] keeps it to this block Luau's [1] needs no brackets round its condition, where LSL's did [1] = [2] and then [2] = [1] leave both [2]: a swap is [3] From a69be2e70eeb85229636b722c8b87c2188910d44 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 18:23:12 -0400 Subject: [PATCH 22/41] A heavy string is a part of its own in a script's weight A string of 256 bytes or more the script keeps -- a key, a table of names, a notecard's worth of text -- is a part of its own, named by the start of its text and placed at the line that first loads it, so the Weights tab lists it as a row beside the functions and handlers. Its bytes are each target's: in SLua's and LSL on Luau's table of strings, taken out of that table's part; in LSO's code, taken out of the function or handler that writes it; in Mono's strings, taken out of the method that first loads it. No byte is counted twice, and each total is as it was. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/alscriptweight.cpp | 130 ++++++++++++++++++- indra/alscript/tests/alscriptweight_test.cpp | 33 ++++- 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/indra/alscript/alscriptweight.cpp b/indra/alscript/alscriptweight.cpp index a2ba1903d0..319b7b5d34 100644 --- a/indra/alscript/alscriptweight.cpp +++ b/indra/alscript/alscriptweight.cpp @@ -41,6 +41,7 @@ #include "Luau/Parser.h" #include +#include #include #include #include @@ -458,6 +459,44 @@ namespace return p; } + // A string the script keeps that weighs this much or more is a part of + // its own -- a key, a table of names, a notecard's worth of text -- each + // target's bytes for it taken out of what held them, so that no byte is + // counted twice. + constexpr size_t HEAVY_CONSTANT = 256; + + // A heavy string as a row names it: the start of its text, quoted, on + // one line. + std::string constantName(std::string_view text) + { + constexpr size_t SHOWN = 32; + std::string out = "\""; + size_t taken = 0; + for (size_t i = 0; i < text.size() && taken < SHOWN; ++i, ++taken) + { + const char c = text[i]; + out += c == '\n' ? std::string("\\n") : c == '\t' ? std::string("\\t") : std::string(1, c); + } + // Not cut inside a character. + while (taken < text.size() && !out.empty() && (static_cast(out.back()) & 0xC0) == 0x80) + { + out.pop_back(); + } + return out + (taken < text.size() ? "\xE2\x80\xA6\"" : "\""); + } + + ALScriptWeight::Part heavyConstant(std::string_view text, size_t bytes, S32 line) + { + ALScriptWeight::Part p = part(ALScriptWeight::Part::Kind::Constant, constantName(text), bytes); + if (line >= 0) + { + p.line = line; + p.column = 0; + p.endLine = line; + } + return p; + } + // The constants an instruction names, by their places in its // prototype's table: where each opcode keeps the index, as Bytecode.h // documents it. @@ -598,6 +637,22 @@ namespace ALScriptWeigh ALScriptWeight by_line; weighAsset(by_line, lined); weight.lines = std::move(by_line.lines); + // The heavy strings likewise, which only lines place: out of the + // table of strings, which is the same in both. + const size_t strings = static_cast(std::find_if(weight.parts.begin(), weight.parts.end(), + [](const ALScriptWeight::Part& one) { + return one.kind == ALScriptWeight::Part::Kind::Constant && one.name == "strings"; + }) - + weight.parts.begin()); + for (ALScriptWeight::Part& one : by_line.parts) + { + if (one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings" && strings < weight.parts.size() && + one.bytes <= weight.parts[strings].bytes) + { + weight.parts[strings].bytes -= one.bytes; + weight.parts.push_back(std::move(one)); + } + } } nameLSLParts(weight, source); return weight; @@ -626,6 +681,7 @@ namespace return; } weight.compiled = true; + const size_t strings_part = weight.parts.size(); weight.parts.push_back(part(ALScriptWeight::Part::Kind::Constant, "strings", read.stringsEnd - read.stringsBegin)); std::map by_line; // The first line, from one, that names each string; nought for none. @@ -709,11 +765,27 @@ namespace } weight.parts.push_back(std::move(one)); } + // The table of strings is a part; each heavy string in it a part of + // its own, at the first line that names it, and no longer the + // table's. The functions' names are the table's. + boost::unordered_flat_set names; + for (const Proto& p : read.protos) + { + if (p.name > 0) + { + names.insert(static_cast(p.name - 1)); + } + } for (size_t s = 0; s < string_at.size(); ++s) { if (string_at[s] > 0 && s < read.stringBytes.size()) { by_line[string_at[s] - 1] += read.stringBytes[s]; + if (read.stringBytes[s] >= HEAVY_CONSTANT && !names.contains(s) && read.stringBytes[s] <= weight.parts[strings_part].bytes) + { + weight.parts[strings_part].bytes -= read.stringBytes[s]; + weight.parts.push_back(heavyConstant(read.strings[s], read.stringBytes[s], string_at[s] - 1)); + } } } for (const auto& [line, bytes] : by_line) @@ -1008,12 +1080,26 @@ namespace explicit LinedLSO(Tailslide::LSOSymbolDataMap& symbols) : LSOBytecodeCompiler(symbols) {} LineMarks marks; + // Each heavy string written, as a part of its own at its line. + std::vector heavy; bool visitSpecific(Tailslide::LSLASTNode* node) override { - marks.enter(node, mCodeBS.size()); + const size_t before = mCodeBS.size(); + marks.enter(node, before); const bool descend = LSOBytecodeCompiler::visitSpecific(node); marks.leave(mCodeBS.size()); + // A string written out where it is used, in the code. + if (node->getNodeSubType() == Tailslide::NODE_CONSTANT_EXPRESSION && mCodeBS.size() >= before + HEAVY_CONSTANT) + { + Tailslide::LSLConstant* value = static_cast(node)->getConstantValue(); + if (value && value->getNodeSubType() == Tailslide::NODE_STRING_CONSTANT) + { + const Tailslide::YYLTYPE* at = node->getLoc(); + heavy.push_back(heavyConstant(static_cast(value)->getValue(), mCodeBS.size() - before, + at && at->first_line > 0 ? zeroBased(at->first_line) : -1)); + } + } return descend; } }; @@ -1098,6 +1184,20 @@ namespace LinedLSO lined(_mSymData); node->visit(&lined); lined.marks.spread(lined.mCodeBS.size(), lines); + // Its heavy strings parts of their own after it, no longer its. + if (parts.empty()) + { + return; + } + const size_t holder = parts.size() - 1; + for (ALScriptWeight::Part& constant : lined.heavy) + { + if (constant.bytes <= parts[holder].bytes) + { + parts[holder].bytes -= constant.bytes; + parts.push_back(std::move(constant)); + } + } } static std::string nameOf(Tailslide::LSLASTNode* node) @@ -1363,6 +1463,8 @@ namespace ALScriptWeigh std::set strings; size_t shared = MONO_BASE_BYTES; ALScriptWeight::Part* method = nullptr; + // Heavy strings, parts of their own once every method is in. + std::vector heavy; ALScriptWeight::Part globals; globals.kind = ALScriptWeight::Part::Kind::Frame; globals.name = "globals"; @@ -1441,8 +1543,29 @@ namespace ALScriptWeigh const size_t cut = line.find(' '); const std::string op = line.substr(0, cut); const std::string rest = cut == std::string::npos ? std::string() : line.substr(cut + 1); - // A string is the first load's, whose line and method made it. - const size_t bytes = ilBytes(op, rest) + (op == "ldstr" && strings.insert(rest).second ? userStringBytes(rest) : 0); + // A string is the first load's, whose line and method made it; + // a heavy one a part of its own, at that line. + size_t bytes = ilBytes(op, rest); + if (op == "ldstr" && strings.insert(rest).second) + { + const size_t text = userStringBytes(rest); + if (text >= HEAVY_CONSTANT) + { + const size_t open = rest.find('"'); + const size_t close = rest.rfind('"'); + heavy.push_back(heavyConstant(open != std::string::npos && close > open ? std::string_view(rest).substr(open + 1, close - open - 1) + : std::string_view(rest), + text, source)); + if (source >= 0) + { + by_line[source] += text; + } + } + else + { + bytes += text; + } + } method->bytes += bytes; if (source >= 0) { @@ -1460,6 +1583,7 @@ namespace ALScriptWeigh } weight.parts.insert(weight.parts.begin(), globals); weight.parts.insert(weight.parts.begin(), part(ALScriptWeight::Part::Kind::Frame, "assembly", shared)); + weight.parts.insert(weight.parts.end(), heavy.begin(), heavy.end()); for (const ALScriptWeight::Part& one : weight.parts) { weight.total += one.bytes; diff --git a/indra/alscript/tests/alscriptweight_test.cpp b/indra/alscript/tests/alscriptweight_test.cpp index 9f8653edcd..0d5450b94d 100644 --- a/indra/alscript/tests/alscriptweight_test.cpp +++ b/indra/alscript/tests/alscriptweight_test.cpp @@ -177,7 +177,38 @@ namespace tut const ALScriptWeight light = ALScriptWeigh::slua("local s = \"x\"\nprint(s)\n"); ensure("both compiled", heavy.compiled && light.compiled); ensure("the string weighs", heavy.total >= light.total + 2000); - ensure("in the strings", named(heavy, "strings") && named(heavy, "strings")->bytes >= 2000); + // Heavy, it is a part of its own, and no longer the table of + // strings'. + const ALScriptWeight::Part* constant = nullptr; + for (const ALScriptWeight::Part& one : heavy.parts) + { + constant = one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings" ? &one : constant; + } + // At the line that loads it: a local never set again is folded into + // where it is used, print(s), as -O1 compiles it. + ensure("a part of its own:" + listed(heavy), constant && constant->bytes >= 2000 && constant->line == 1); + ensure("named by its start: " + (constant ? constant->name : std::string()), constant && constant->name.rfind("\"xxxx", 0) == 0); + ensure("not the strings' too", named(heavy, "strings") && named(heavy, "strings")->bytes < 100); + ensure("a light one stays the strings'", std::none_of(light.parts.begin(), light.parts.end(), [](const ALScriptWeight::Part& one) { + return one.kind == ALScriptWeight::Part::Kind::Constant && one.name != "strings"; + })); + // And for the LSL targets: LSO's in its code, Mono's in its strings, + // LSL on Luau's in its table; each a part of its own, out of the + // handler that held it, the whole as it was. + const std::string lsl = "default { state_entry() { llOwnerSay(\"" + words + "\"); } }\n"; + for (const ALScriptWeight& one : { ALScriptWeigh::lso(lsl), ALScriptWeigh::mono(lsl), ALScriptWeigh::lslLuau(lsl) }) + { + const ALScriptWeight::Part* found = nullptr; + size_t sum = 0; + for (const ALScriptWeight::Part& part : one.parts) + { + found = part.kind == ALScriptWeight::Part::Kind::Constant && part.name != "strings" ? &part : found; + sum += part.bytes; + } + const std::string target = ALScriptWeight::nameOf(one.target); + ensure(target + ": a part of its own:" + listed(one), one.compiled && found && found->bytes >= 2000 && found->line == 0); + ensure(target + ": counted once", sum <= one.total); + } const ALScriptWeight broken = ALScriptWeigh::slua("local function (\n"); ensure("a script that does not compile", !broken.compiled && !broken.error.empty()); } From 9801790ba746a745ad6efe1e5f5e5a3ee95ab693 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 18:30:01 -0400 Subject: [PATCH 23/41] An estate manager sees each object's script time beside its memory The region tells its scripts' time only to an estate manager, through Top Scripts. Where the agent manages the estate, the studio asks Top Scripts of each owner of the objects it has in hand -- the agent, or one whose name is known -- once a minute an owner, and keeps each object's time by its id beside what the region said it reserves: the Explorer's object rows say it after the memory, and the Weights tab's heading as a sentence of its own. Memory and time each keep what the other said. The region's answer says neither who asked nor what for, and had one handler, the Top Objects floater's. It is the workspace's now: an answer of top scripts filtered by owner while the studio waits on one is the studio's, and every other goes on to Top Objects as before. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alscriptexplorerpane.cpp | 22 ++++- indra/newview/alscriptregionusage.cpp | 57 ++++++++++- indra/newview/alscriptregionusage.h | 31 +++++- indra/newview/alscriptweightspane.cpp | 10 +- indra/newview/alscriptworkspace.cpp | 94 +++++++++++++++++++ indra/newview/alscriptworkspace.h | 4 + indra/newview/llstartup.cpp | 2 +- .../default/xui/en/floater_script_studio.xml | 2 + .../tests/alscriptregionusage_test.cpp | 56 +++++++++++ .../tests/alscriptweightspane_test.cpp | 14 +++ 10 files changed, 281 insertions(+), 11 deletions(-) diff --git a/indra/newview/alscriptexplorerpane.cpp b/indra/newview/alscriptexplorerpane.cpp index fba051192d..77ceb55c31 100644 --- a/indra/newview/alscriptexplorerpane.cpp +++ b/indra/newview/alscriptexplorerpane.cpp @@ -553,9 +553,25 @@ void ALScriptExplorerPane::fill() : bracketed(mServices->words("KindAwayIn", where)); if (const ALScriptRegionUsage::Usage* reserved = row.present ? usage.usageOf(row.value["root"].asUUID()) : nullptr) { - LLStringUtil::format_map_t size; - size["[RESERVED]"] = llformat("%f", (F64)reserved->memory / 1024.0); - out.suffix += bracketed(mServices->words("ExplorerReserved", size)); + // What it reserves, and the time its scripts take where + // the region tells an estate manager. + std::string said; + if (reserved->hasMemory()) + { + LLStringUtil::format_map_t size; + size["[RESERVED]"] = llformat("%f", (F64)reserved->memory / 1024.0); + said = mServices->words("ExplorerReserved", size); + } + if (reserved->hasTime()) + { + LLStringUtil::format_map_t time; + time["[TIME]"] = llformat("%.3f", reserved->time); + said += (said.empty() ? "" : ", ") + mServices->words("ExplorerTime", time); + } + if (!said.empty()) + { + out.suffix += bracketed(said); + } } out.icon = row.many ? "Inv_Object_Multi" : "Inv_Object"; break; diff --git a/indra/newview/alscriptregionusage.cpp b/indra/newview/alscriptregionusage.cpp index f7d876950b..d77b7429e8 100644 --- a/indra/newview/alscriptregionusage.cpp +++ b/indra/newview/alscriptregionusage.cpp @@ -26,6 +26,8 @@ #include "alscriptregionusage.h" +#include + namespace { // The objects of a list of groups -- an answer's attachment points, or @@ -118,6 +120,38 @@ void ALScriptRegionUsage::ask(const std::vector& roots, F64 now) } }); } + // Time, of an estate manager: each owner's objects at once, an owner at + // most once a minute, whatever was asked of memory. + if (!mWorld.mayAskTime || !mWorld.askTime || !mWorld.ownerOf || !mWorld.mayAskTime()) + { + return; + } + std::vector owners; + for (const LLUUID& root : roots) + { + if (!mWorld.kindOf || mWorld.kindOf(root) == World::Kind::None) + { + continue; + } + const std::string owner = mWorld.ownerOf(root); + const auto asked = mTimeAsked.find(owner); + if (owner.empty() || (asked != mTimeAsked.end() && now - asked->second < ASK_EVERY) || + std::find(owners.begin(), owners.end(), owner) != owners.end()) + { + continue; + } + mTimeAsked[owner] = now; + owners.push_back(owner); + } + for (const std::string& owner : owners) + { + mWorld.askTime(owner, [this, alive](const times_t& times) { + if (alive.lock()) + { + heardTimes(times, LLDate::now()); + } + }); + } } // static @@ -139,12 +173,31 @@ void ALScriptRegionUsage::heard(const usages_t& usages) return; } // Each object named is answered for as of the last asking, asked with - // the others or not: a parcel's answer is every object on it. + // the others or not: a parcel's answer is every object on it. Its time, + // where said, stays. for (const auto& [id, usage] : usages) { - mUsages[id] = usage; + Usage& kept = mUsages[id]; + kept.memory = usage.memory; + kept.urls = usage.urls; + kept.when = usage.when; const auto asked = mAsked.find(id); mAsked[id] = asked != mAsked.end() ? std::max(asked->second, mNow) : mNow; } mHeard(); } + +void ALScriptRegionUsage::heardTimes(const times_t& times, const LLDate& when) +{ + if (times.empty()) + { + return; + } + for (const auto& [id, time] : times) + { + Usage& kept = mUsages[id]; + kept.time = time; + kept.timeWhen = when; + } + mHeard(); +} diff --git a/indra/newview/alscriptregionusage.h b/indra/newview/alscriptregionusage.h index 038717cfbb..922cd22642 100644 --- a/indra/newview/alscriptregionusage.h +++ b/indra/newview/alscriptregionusage.h @@ -39,7 +39,8 @@ // hand, as it tells the Script Limits and My Scripts floaters: memory and // URLs for each object, by its root. Never for each script, and never what // a script uses: the region counts a Mono script at its limit, 64 KB unless -// llSetMemoryLimit says less. Time it tells only an estate manager. +// llSetMemoryLimit says less. Time it tells only an estate manager, by Top +// Scripts: each object's, asked for by its owner's name. // // An attachment of the agent's is asked of AttachmentResources, which // answers for all of them at once; an object on land, of LandResources, @@ -52,11 +53,20 @@ class ALScriptRegionUsage public: struct Usage { - // Bytes. + // Bytes; and when the region said so, an epoch's nought where it has + // not. S64 memory = 0; S32 urls = 0; - LLDate when; + LLDate when = LLDate(0.0); + // The time its scripts take, in milliseconds a frame, as Top Scripts + // says it to an estate manager; negative where it has not said. + F32 time = -1.f; + LLDate timeWhen = LLDate(0.0); + bool hasMemory() const { return when.secondsSinceEpoch() > 0.0; } + bool hasTime() const { return time >= 0.f; } }; + // Time said, by each object's id. + typedef boost::unordered_flat_map times_t; struct World { @@ -76,6 +86,15 @@ class ALScriptRegionUsage // The parcel an object stands on asked about, its details' answer // to `told` the same way. std::function told)> askLand; + // Whether the region would tell the agent its scripts' time: an + // estate manager of it. + std::function mayAskTime; + // The name Top Scripts knows an object's owner by; empty where it + // is not known, and the object's time is not asked for. + std::function ownerOf; + // Top Scripts asked for, of one owner's objects; `told` with each + // one's time, once, or not at all. + std::function told)> askTime; }; // How long an answer stands before an object is asked about again. @@ -95,8 +114,10 @@ class ALScriptRegionUsage static void readAttachments(const LLSD& answer, const LLDate& when, usages_t& out); static void readDetails(const LLSD& answer, const LLDate& when, usages_t& out); - // What an answer said kept, and heard: the objects it named. + // What an answer said kept, and heard: the objects it named. Memory + // and time each keep what the other said. void heard(const usages_t& usages); + void heardTimes(const times_t& times, const LLDate& when); typedef boost::signals2::signal heard_signal_t; boost::signals2::connection onHeard(const heard_signal_t::slot_type& slot) { return mHeard.connect(slot); } @@ -106,6 +127,8 @@ class ALScriptRegionUsage // When each object, and the attachments, were last asked about. boost::unordered_flat_map mAsked; F64 mAttachmentsAsked = -ASK_EVERY; + // When each owner's objects' time was last asked for. + boost::unordered_flat_map mTimeAsked; // The clock as the last asking said it, which an answer is counted by. F64 mNow = 0.0; heard_signal_t mHeard; diff --git a/indra/newview/alscriptweightspane.cpp b/indra/newview/alscriptweightspane.cpp index 689a911b92..41a4c3ed9c 100644 --- a/indra/newview/alscriptweightspane.cpp +++ b/indra/newview/alscriptweightspane.cpp @@ -314,13 +314,21 @@ void ALScriptWeightsPane::fillParts() head += " " + mStrings->getString("WeightsHeadBefore"); } } - if (mShown.region) + if (mShown.region && mShown.region->hasMemory()) { args["[RESERVED]"] = llformat("%f", (F64)mShown.region->memory / 1024.0); args["[URLS]"] = std::to_string(mShown.region->urls); args["[WHEN]"] = ALRecoveryEntry::sayWhen(mShown.region->when); head += " " + mStrings->getString(mShown.region->urls > 0 ? "WeightsHeadRegionUrls" : "WeightsHeadRegion", args); } + // And the time its scripts take, where the region tells an estate + // manager. + if (mShown.region && mShown.region->hasTime()) + { + args["[TIME]"] = llformat("%.3f", mShown.region->time); + args["[WHEN]"] = ALRecoveryEntry::sayWhen(mShown.region->timeWhen); + head += " " + mStrings->getString("WeightsHeadRegionTime", args); + } mHead->setText(head); mHead->setToolTip(mStrings->getString(mShown.region ? "WeightsHeadRegionTip" : "WeightsHeadTip")); diff --git a/indra/newview/alscriptworkspace.cpp b/indra/newview/alscriptworkspace.cpp index d56e4bf581..1760e71a19 100644 --- a/indra/newview/alscriptworkspace.cpp +++ b/indra/newview/alscriptworkspace.cpp @@ -36,6 +36,7 @@ #include "alscripttempfiles.h" #include "lldbstrings.h" #include "llagent.h" +#include "llavatarnamecache.h" #include "llcorehttputil.h" #include "llappviewer.h" #include "llassetstorage.h" @@ -45,6 +46,7 @@ #include "lleventtimer.h" #include "llfilesystem.h" #include "llfloaterperms.h" +#include "llfloatertopobjects.h" #include "llinventory.h" #include "llinventorydefines.h" #include "llinventorymodel.h" @@ -74,6 +76,8 @@ #include +#include + #include #include #include @@ -266,6 +270,23 @@ namespace } // What the region reserves, asked of the viewer's world. + // Top Scripts asked for by the studio, oldest first, each given up once + // its answer has had time to come. The region's answer, LandStatReply, + // says neither who asked nor what for: one of top scripts filtered by + // owner while one of these waits is the studio's, and any other the Top + // Objects floater's, as before. + struct TimeAsked + { + F64 until = 0.0; + std::function told; + }; + std::deque& timesAsked() + { + static std::deque asked; + return asked; + } + constexpr F64 TIME_ANSWER_WITHIN = 30.0; + ALScriptRegionUsage::World regionUsageWorld() { typedef ALScriptRegionUsage::World World; @@ -325,6 +346,39 @@ namespace }); }); }; + world.mayAskTime = []() { return gAgent.canManageEstate(); }; + world.ownerOf = [](const LLUUID& root) -> std::string { + // By the name Top Scripts gives an owner, the legacy one: the + // agent's own objects, or another's whose name is in hand. A + // group's, or one not known yet, is not asked about. + const LLViewerObject* object = gObjectList.findObject(root); + if (!object) + { + return std::string(); + } + const LLUUID owner = object->permYouOwner() ? gAgentID : object->mOwnerID; + LLAvatarName name; + return owner.notNull() && LLAvatarNameCache::get(owner, &name) ? name.getLegacyName() : std::string(); + }; + world.askTime = [](const std::string& owner, std::function told) { + LLViewerRegion* region = gAgent.getRegion(); + if (!region || !gMessageSystem) + { + return; + } + timesAsked().push_back({ LLTimer::getTotalSeconds() + TIME_ANSWER_WITHIN, std::move(told) }); + LLMessageSystem* msg = gMessageSystem; + msg->newMessageFast(_PREHASH_LandStatRequest); + msg->nextBlockFast(_PREHASH_AgentData); + msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID()); + msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID()); + msg->nextBlockFast(_PREHASH_RequestData); + msg->addU32Fast(_PREHASH_ReportType, STAT_REPORT_TOP_SCRIPTS); + msg->addU32Fast(_PREHASH_RequestFlags, STAT_FILTER_BY_OWNER); + msg->addStringFast(_PREHASH_Filter, owner); + msg->addS32Fast(_PREHASH_ParcelLocalID, 0); + msg->sendReliable(region->getHost()); + }; return world; } } @@ -1758,6 +1812,46 @@ bool ALScriptWorkspace::askRunning(const ALScriptRef& ref) } // static +void ALScriptWorkspace::processLandStatReply(LLMessageSystem* msg, void** data) +{ + // Those given up on gone; then the oldest still waiting takes an answer + // of top scripts by owner, and anything else is Top Objects'. + std::deque& asked = timesAsked(); + const F64 now = LLTimer::getTotalSeconds(); + while (!asked.empty() && asked.front().until < now) + { + asked.pop_front(); + } + U32 report = 0; + U32 flags = 0; + msg->getU32Fast(_PREHASH_RequestData, _PREHASH_ReportType, report); + msg->getU32Fast(_PREHASH_RequestData, _PREHASH_RequestFlags, flags); + if (asked.empty() || report != STAT_REPORT_TOP_SCRIPTS || (flags & STAT_FILTER_BY_OWNER) == 0) + { + LLFloaterTopObjects::handle_land_reply(msg, data); + return; + } + const auto told = std::move(asked.front().told); + asked.pop_front(); + ALScriptRegionUsage::times_t times; + const S32 count = msg->getNumberOfBlocksFast(_PREHASH_ReportData); + for (S32 block = 0; block < count; ++block) + { + LLUUID task; + F32 score = 0.f; + msg->getUUIDFast(_PREHASH_ReportData, _PREHASH_TaskID, task, block); + msg->getF32Fast(_PREHASH_ReportData, _PREHASH_Score, score, block); + if (task.notNull()) + { + times[task] = score; + } + } + if (told) + { + told(times); + } +} + void ALScriptWorkspace::processScriptRunningReply(LLMessageSystem* msg, void** data) { ALScriptRunningState state; diff --git a/indra/newview/alscriptworkspace.h b/indra/newview/alscriptworkspace.h index 430b0e6488..ae4a8e500c 100644 --- a/indra/newview/alscriptworkspace.h +++ b/indra/newview/alscriptworkspace.h @@ -177,6 +177,10 @@ class ALScriptWorkspace : public LLSingleton // The region's answer, registered for the message; the legacy live // editor hears it through here. static void processScriptRunningReply(LLMessageSystem* msg, void** data); + // The region's answer to Top Scripts and Top Colliders, registered for + // the message: the studio's where it asked for its objects' time + // (ALScriptRegionUsage), else the Top Objects floater's. + static void processLandStatReply(LLMessageSystem* msg, void** data); // The experience a script in an object runs under, asked of its // region: the null one for none -- and for a script in the inventory, diff --git a/indra/newview/llstartup.cpp b/indra/newview/llstartup.cpp index af710f33be..c0c17db063 100644 --- a/indra/newview/llstartup.cpp +++ b/indra/newview/llstartup.cpp @@ -3237,7 +3237,7 @@ void register_viewer_callbacks(LLMessageSystem* msg) msg->setHandlerFuncFast(_PREHASH_ParcelObjectOwnersReply, LLPanelLandObjects::processParcelObjectOwnersReply); msg->setHandlerFuncFast(_PREHASH_InitiateDownload, process_initiate_download); - msg->setHandlerFuncFast(_PREHASH_LandStatReply, LLFloaterTopObjects::handle_land_reply); + msg->setHandlerFuncFast(_PREHASH_LandStatReply, ALScriptWorkspace::processLandStatReply); msg->setHandlerFuncFast(_PREHASH_GenericMessage, process_generic_message); msg->setHandlerFuncFast(_PREHASH_GenericStreamingMessage, process_generic_streaming_message); msg->setHandlerFuncFast(_PREHASH_LargeGenericMessage, process_large_generic_message); diff --git a/indra/newview/skins/default/xui/en/floater_script_studio.xml b/indra/newview/skins/default/xui/en/floater_script_studio.xml index 03df89f294..33cc76b32c 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio.xml @@ -679,6 +679,8 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is In the region, its object reserves [RESERVED,number,0] KB and [URLS] URLs for all its scripts, as of [WHEN]. Code size only, not counting the lists and strings the script builds while it runs. The region counts the memory of every script in the object, each at its limit (64 KB for Mono unless llSetMemoryLimit lowers it), not what it uses. [RESERVED,number,0] KB reserved + [TIME] ms a frame + Its scripts take [TIME] ms of the region's time a frame, as of [WHEN]. [TARGET]: [BYTES] bytes of code, of [MAX] bytes of memory. [TARGET]: about [BYTES] bytes of code, of [MAX] bytes of memory, estimated from the compiled code. Does not compile for [TARGET]: [ERROR] diff --git a/indra/newview/tests/alscriptregionusage_test.cpp b/indra/newview/tests/alscriptregionusage_test.cpp index 5c2805b0b9..ff717ee883 100644 --- a/indra/newview/tests/alscriptregionusage_test.cpp +++ b/indra/newview/tests/alscriptregionusage_test.cpp @@ -44,6 +44,11 @@ namespace tut std::map kinds; std::vector attachmentAsks; std::vector> landAsks; + // Time: whether the agent manages the estate, each object's owner, + // and the askings of Top Scripts not answered yet. + bool estateManager = false; + std::map owners; + std::vector>> timeAsks; Usage usage; S32 heard = 0; boost::signals2::scoped_connection heardConnection; @@ -62,6 +67,14 @@ namespace tut }; out.askAttachments = [this](Told told) { attachmentAsks.push_back(std::move(told)); }; out.askLand = [this](const LLUUID& root, Told told) { landAsks.emplace_back(root, std::move(told)); }; + out.mayAskTime = [this]() { return estateManager; }; + out.ownerOf = [this](const LLUUID& root) { + const auto found = owners.find(root); + return found != owners.end() ? found->second : std::string(); + }; + out.askTime = [this](const std::string& owner, std::function told) { + timeAsks.emplace_back(owner, std::move(told)); + }; return out; } @@ -186,4 +199,47 @@ namespace tut late(parcel); ensure("the one still here heard", usage.usageOf(id(3)) != nullptr); } + + template<> template<> + void alscriptregionusage_object::test<4>() + { + set_test_name("time, of an estate manager only: each owner's objects asked for at once, once a minute, an owner unknown not at all; kept with what memory said, and memory kept with it"); + kinds[id(1)] = Kind::Land; + kinds[id(2)] = Kind::Land; + kinds[id(3)] = Kind::Attachment; + kinds[id(4)] = Kind::Land; + owners[id(1)] = "Ann Resident"; + owners[id(2)] = "Ann Resident"; + owners[id(3)] = "Bo Smith"; + usage.ask({ id(1), id(2), id(3), id(4) }, 100.0); + ensure("not an estate manager: not asked", timeAsks.empty()); + + estateManager = true; + attachmentAsks.clear(); + landAsks.clear(); + usage.ask({ id(1), id(2), id(3), id(4) }, 100.0); + ensure_equals("an asking an owner", timeAsks.size(), 2U); + ensure("each owner once, none for one unknown", timeAsks[0].first == "Ann Resident" && timeAsks[1].first == "Bo Smith"); + usage.ask({ id(1) }, 130.0); + ensure("not again within a minute", timeAsks.size() == 2); + usage.ask({ id(1) }, 161.0); + ensure("again after it", timeAsks.size() == 3 && timeAsks[2].first == "Ann Resident"); + + // Memory first, then time: both kept. + LLSD details; + details["parcels"][0]["objects"].append(object(id(1), 65536, 0)); + landAsks[0].second(details); + Usage::times_t times; + times[id(1)] = 0.125f; + times[id(5)] = 2.0f; + const S32 before = heard; + timeAsks[0].second(times); + ensure("heard", heard == before + 1); + const ALScriptRegionUsage::Usage* one = usage.usageOf(id(1)); + ensure("its time and its memory", one && one->hasTime() && one->time == 0.125f && one->hasMemory() && one->memory == 65536); + ensure("an object not asked about kept too, time alone", usage.usageOf(id(5)) && !usage.usageOf(id(5))->hasMemory()); + // Memory again: the time stays. + landAsks[0].second(details); + ensure("time kept through memory", usage.usageOf(id(1))->time == 0.125f); + } } diff --git a/indra/newview/tests/alscriptweightspane_test.cpp b/indra/newview/tests/alscriptweightspane_test.cpp index 666ffd19f8..b851571de1 100644 --- a/indra/newview/tests/alscriptweightspane_test.cpp +++ b/indra/newview/tests/alscriptweightspane_test.cpp @@ -363,6 +363,20 @@ namespace tut shown.id = "script-3"; pane.show(shown); ensure("and URLs where there are" + head->getText(), head->getText().find("128 KB and 3 URLs") != std::string::npos); + ensure("no time where the region said none" + head->getText(), head->getText().find("ms of the region") == std::string::npos); + shown.region->time = 0.125f; + shown.region->timeWhen = LLDate(1.8e9); + shown.id = "script-4"; + pane.show(shown); + ensure("its time, said to an estate manager" + head->getText(), head->getText().find("take 0.125 ms of the region's time") != std::string::npos); + ALScriptRegionUsage::Usage timed; + timed.time = 0.5f; + timed.timeWhen = LLDate(1.8e9); + shown.region = timed; + shown.id = "script-5"; + pane.show(shown); + ensure("time alone: no memory said" + head->getText(), + head->getText().find("reserves") == std::string::npos && head->getText().find("take 0.500 ms") != std::string::npos); } // LSL on Luau's compiler records no lines, so its lines are not From f2b36c00c868eea3da6409752523f94984a7a940 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 19:07:06 -0400 Subject: [PATCH 24/41] The find bar's Next and Previous let the other carets go, as F3 does With several carets, F3 went to the next match with the main caret alone, but the find bar's buttons kept the others where they were. Both come to the same function, which now lets the others go when it finds a match, so the bar and the keys agree. Co-Authored-By: Claude Opus 5.5 --- indra/llui/altextview.cpp | 3 +++ indra/llui/tests/altextview_test.cpp | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/indra/llui/altextview.cpp b/indra/llui/altextview.cpp index e04f0a3b32..d574f14e78 100644 --- a/indra/llui/altextview.cpp +++ b/indra/llui/altextview.cpp @@ -4312,6 +4312,9 @@ bool ALTextView::findNext(bool forward) // one at or after it. const S32 wrapped = forward ? (match.begin < from ? 1 : 0) : (match.begin < from ? 0 : -1); mFind.setCurrent(index); + // The match alone, the other carets let go, from the bar's buttons as + // from the keys. + singleSelection(); setSelection(match); mFindBar->setCount(mFind.current(), static_cast(mFind.count()), mFind.error(), mFind.capped(), wrapped); return true; diff --git a/indra/llui/tests/altextview_test.cpp b/indra/llui/tests/altextview_test.cpp index f9f2a1a7b5..31f53a7ca9 100644 --- a/indra/llui/tests/altextview_test.cpp +++ b/indra/llui/tests/altextview_test.cpp @@ -2389,4 +2389,20 @@ namespace tut v.showFind(false); ensure_equals("the word at the main caret, not another's selection", v.findBar()->query(), std::string("cat")); } + + template<> template<> + void altextview_object::test<76>() + { + set_test_name("the find bar's next and previous let the other carets go, as F3 does"); + ALTextView& v = make("cat dog\nbird cat"); + v.setSelections(spanRange(0, 0, 3), { caretRange(1, 2) }); + v.showFind(false); + ensure("found from the bar", v.findNext(true)); + ensure("the other caret let go", !v.hasOtherSelections()); + ensure("at the next match", v.selection().normalised() == spanRange(1, 5, 8)); + v.setSelections(spanRange(1, 5, 8), { caretRange(0, 5) }); + ensure("found back from the bar", v.findNext(false)); + ensure("the other caret let go going back", !v.hasOtherSelections()); + ensure("at the match before", v.selection().normalised() == spanRange(0, 0, 3)); + } } From ab820f8f395594039207f3540c2fb6f84f77064a Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 19:07:06 -0400 Subject: [PATCH 25/41] The offline reference files functions by the region's categories The reference grouped functions by the categories in the shipped lsl_definitions.yaml, while their names and signatures came from what the region sent. It reads the YAML through the syntax cache now, as the keywords are read: the region's where it sent one, else the shipped one. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alfloaterscriptstudio.cpp | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/indra/newview/alfloaterscriptstudio.cpp b/indra/newview/alfloaterscriptstudio.cpp index 3907d9008a..9d243cf752 100644 --- a/indra/newview/alfloaterscriptstudio.cpp +++ b/indra/newview/alfloaterscriptstudio.cpp @@ -154,13 +154,9 @@ namespace return lua ? LLSyntaxDefCache::instance().getLuaKeywords() : LLSyntaxDefCache::instance().getLSLKeywords(); } std::string definitionsVersion() override { return LLSyntaxDefCache::instance().getSyntaxID().asString(); } - std::string definitionsYaml() override - { - llifstream in(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, "syntax_default", "lsl_definitions.yaml"), std::ios::binary); - std::stringstream text; - text << in.rdbuf(); - return text.str(); - } + // The region's, where it delivered them, as the keywords are; else + // those shipped. + std::string definitionsYaml() override { return LLSyntaxDefCache::instance().loadCacheFile("lsl_definitions.yaml"); } std::vector preprocessorWords() override { std::vector words; From 573c566d2cafbdec02ba336d00dfdda33ab1c4ff Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 19:07:06 -0400 Subject: [PATCH 26/41] The Weights heading's tip says how each thing it says is counted The tip said how the region counts memory whenever the region had said anything, and nothing of time. It is built from parts now: the code size always; how the region counts memory, where it said what the object reserves; and how its time is counted, where an estate manager was told it. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alscriptweightspane.cpp | 12 +++++++++++- .../skins/default/xui/en/floater_script_studio.xml | 3 ++- indra/newview/tests/alscriptweightspane_test.cpp | 6 +++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/indra/newview/alscriptweightspane.cpp b/indra/newview/alscriptweightspane.cpp index 41a4c3ed9c..01fd885f60 100644 --- a/indra/newview/alscriptweightspane.cpp +++ b/indra/newview/alscriptweightspane.cpp @@ -330,7 +330,17 @@ void ALScriptWeightsPane::fillParts() head += " " + mStrings->getString("WeightsHeadRegionTime", args); } mHead->setText(head); - mHead->setToolTip(mStrings->getString(mShown.region ? "WeightsHeadRegionTip" : "WeightsHeadTip")); + // The tip says how each of what the head says is counted. + std::string tip = mStrings->getString("WeightsHeadTip"); + if (mShown.region && mShown.region->hasMemory()) + { + tip += " " + mStrings->getString("WeightsHeadRegionTip"); + } + if (mShown.region && mShown.region->hasTime()) + { + tip += " " + mStrings->getString("WeightsHeadRegionTimeTip"); + } + mHead->setToolTip(tip); // Each part, with how it moved since the text was last saved, where it // was weighed for this target then. diff --git a/indra/newview/skins/default/xui/en/floater_script_studio.xml b/indra/newview/skins/default/xui/en/floater_script_studio.xml index 33cc76b32c..4efe1b3bfb 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio.xml @@ -677,10 +677,11 @@ A notecard dropped on the vimrc box in Script Studio Preferences (Editor tab) is Code size only, not counting the lists and strings the script builds while it runs. In the region, its object reserves [RESERVED,number,0] KB for all its scripts, as of [WHEN]. In the region, its object reserves [RESERVED,number,0] KB and [URLS] URLs for all its scripts, as of [WHEN]. - Code size only, not counting the lists and strings the script builds while it runs. The region counts the memory of every script in the object, each at its limit (64 KB for Mono unless llSetMemoryLimit lowers it), not what it uses. + The region counts the memory of every script in the object, each at its limit (64 KB for Mono unless llSetMemoryLimit lowers it), not what it uses. [RESERVED,number,0] KB reserved [TIME] ms a frame Its scripts take [TIME] ms of the region's time a frame, as of [WHEN]. + The time is the region's own count for all the object's scripts, on average, as its Top Scripts list shows an estate manager. [TARGET]: [BYTES] bytes of code, of [MAX] bytes of memory. [TARGET]: about [BYTES] bytes of code, of [MAX] bytes of memory, estimated from the compiled code. Does not compile for [TARGET]: [ERROR] diff --git a/indra/newview/tests/alscriptweightspane_test.cpp b/indra/newview/tests/alscriptweightspane_test.cpp index b851571de1..db6b0db97b 100644 --- a/indra/newview/tests/alscriptweightspane_test.cpp +++ b/indra/newview/tests/alscriptweightspane_test.cpp @@ -335,7 +335,8 @@ namespace tut // What the region reserves for the script's object, where it has said: // after the code's size, for all of the object's scripts, with its URLs - // where it has any; and the tip saying how the region counts. + // where it has any; its time, where it told an estate manager; and the + // tip saying how the region counts each. template<> template<> void alscriptweightspane_object::test<7>() { @@ -359,6 +360,7 @@ namespace tut ensure("as of when" + said, said.find(ALRecoveryEntry::sayWhen(reserved.when)) != std::string::npos); ensure("no URLs said where there are none" + said, said.find("URLs") == std::string::npos); ensure("the tip says how it counts", head->getToolTip().find("64 KB for Mono") != std::string::npos); + ensure("and nothing of time unasked", head->getToolTip().find("Top Scripts") == std::string::npos); shown.region->urls = 3; shown.id = "script-3"; pane.show(shown); @@ -369,6 +371,7 @@ namespace tut shown.id = "script-4"; pane.show(shown); ensure("its time, said to an estate manager" + head->getText(), head->getText().find("take 0.125 ms of the region's time") != std::string::npos); + ensure("the tip says how both are counted", head->getToolTip().find("64 KB for Mono") != std::string::npos && head->getToolTip().find("Top Scripts") != std::string::npos); ALScriptRegionUsage::Usage timed; timed.time = 0.5f; timed.timeWhen = LLDate(1.8e9); @@ -377,6 +380,7 @@ namespace tut pane.show(shown); ensure("time alone: no memory said" + head->getText(), head->getText().find("reserves") == std::string::npos && head->getText().find("take 0.500 ms") != std::string::npos); + ensure("and the tip of the time alone", head->getToolTip().find("64 KB for Mono") == std::string::npos && head->getToolTip().find("Top Scripts") != std::string::npos); } // LSL on Luau's compiler records no lines, so its lines are not From 50a71e481e37b5b76f383a7dc81aa0e42e597140 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 19:07:07 -0400 Subject: [PATCH 27/41] Top Scripts answers are told apart by the owner their rows name An answer of top scripts filtered by owner says neither who asked nor what for, so while the studio waited on one it took the first that came, even one the Top Objects floater asked for with its own owner filter. Each of the studio's asks keeps its owner now, and an answer is the oldest one's whose owner its rows name, names compared as usernames. One naming none of them -- empty, or another owner's -- is the oldest ask's only while Top Objects is closed, and Top Objects' while it is open. Co-Authored-By: Claude Opus 5.5 --- indra/newview/alscriptregionusage.cpp | 36 ++++++++++++++++ indra/newview/alscriptregionusage.h | 13 ++++++ indra/newview/alscriptworkspace.cpp | 43 +++++++++++++------ .../tests/alscriptregionusage_test.cpp | 18 ++++++++ 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/indra/newview/alscriptregionusage.cpp b/indra/newview/alscriptregionusage.cpp index d77b7429e8..d7fd8dd9b2 100644 --- a/indra/newview/alscriptregionusage.cpp +++ b/indra/newview/alscriptregionusage.cpp @@ -26,6 +26,8 @@ #include "alscriptregionusage.h" +#include "llcachename.h" + #include namespace @@ -201,3 +203,37 @@ void ALScriptRegionUsage::heardTimes(const times_t& times, const LLDate& when) } mHeard(); } + +// static +std::optional ALScriptRegionUsage::answering(const std::vector& waiting, const std::vector& named, + bool topObjectsOpen) +{ + std::vector keys; + keys.reserve(named.size()); + for (const std::string& name : named) + { + keys.push_back(ownerKey(name)); + } + for (size_t i = 0; i < waiting.size(); ++i) + { + if (std::find(keys.begin(), keys.end(), ownerKey(waiting[i])) != keys.end()) + { + return i; + } + } + if (!waiting.empty() && !topObjectsOpen) + { + return 0; + } + return std::nullopt; +} + +// static +std::string ALScriptRegionUsage::ownerKey(std::string name) +{ + // The region sends names with spaces after, at times. + LLStringUtil::trim(name); + name = LLCacheName::buildUsername(name); + LLStringUtil::toLower(name); + return name; +} diff --git a/indra/newview/alscriptregionusage.h b/indra/newview/alscriptregionusage.h index 922cd22642..97e0172c5d 100644 --- a/indra/newview/alscriptregionusage.h +++ b/indra/newview/alscriptregionusage.h @@ -33,6 +33,8 @@ #include #include +#include +#include #include // What the region reserves for the scripts of the objects a studio has in @@ -118,6 +120,17 @@ class ALScriptRegionUsage // and time each keep what the other said. void heard(const usages_t& usages); void heardTimes(const times_t& times, const LLDate& when); + + // Which of the asks for time waiting, oldest first, by owner, an answer + // of Top Scripts by owner is for, the owners its rows name given: the + // oldest one whose owner they name; else, where nothing else could + // have asked -- Top Objects closed -- the oldest; else none, and the + // answer is Top Objects'. The region's answer says neither who asked + // nor what for. Names compared by ownerKey. + static std::optional answering(const std::vector& waiting, const std::vector& named, bool topObjectsOpen); + // An owner's name as a username, lowered, whichever form it came in: + // "First Resident" and "first" alike. + static std::string ownerKey(std::string name); typedef boost::signals2::signal heard_signal_t; boost::signals2::connection onHeard(const heard_signal_t::slot_type& slot) { return mHeard.connect(slot); } diff --git a/indra/newview/alscriptworkspace.cpp b/indra/newview/alscriptworkspace.cpp index 1760e71a19..c8716c748d 100644 --- a/indra/newview/alscriptworkspace.cpp +++ b/indra/newview/alscriptworkspace.cpp @@ -46,6 +46,7 @@ #include "lleventtimer.h" #include "llfilesystem.h" #include "llfloaterperms.h" +#include "llfloaterreg.h" #include "llfloatertopobjects.h" #include "llinventory.h" #include "llinventorydefines.h" @@ -270,14 +271,16 @@ namespace } // What the region reserves, asked of the viewer's world. - // Top Scripts asked for by the studio, oldest first, each given up once - // its answer has had time to come. The region's answer, LandStatReply, - // says neither who asked nor what for: one of top scripts filtered by - // owner while one of these waits is the studio's, and any other the Top - // Objects floater's, as before. + // Top Scripts asked for by the studio, oldest first, by owner, each + // given up once its answer has had time to come. The region's answer, + // LandStatReply, says neither who asked nor what for: which of these an + // answer of top scripts by owner is for, if any, is + // ALScriptRegionUsage::answering's to say, and any other answer is the + // Top Objects floater's, as before. struct TimeAsked { F64 until = 0.0; + std::string owner; std::function told; }; std::deque& timesAsked() @@ -366,7 +369,7 @@ namespace { return; } - timesAsked().push_back({ LLTimer::getTotalSeconds() + TIME_ANSWER_WITHIN, std::move(told) }); + timesAsked().push_back({ LLTimer::getTotalSeconds() + TIME_ANSWER_WITHIN, owner, std::move(told) }); LLMessageSystem* msg = gMessageSystem; msg->newMessageFast(_PREHASH_LandStatRequest); msg->nextBlockFast(_PREHASH_AgentData); @@ -1814,8 +1817,8 @@ bool ALScriptWorkspace::askRunning(const ALScriptRef& ref) // static void ALScriptWorkspace::processLandStatReply(LLMessageSystem* msg, void** data) { - // Those given up on gone; then the oldest still waiting takes an answer - // of top scripts by owner, and anything else is Top Objects'. + // Those given up on gone; then an answer of top scripts by owner is the + // studio's where answering says whose, and anything else Top Objects'. std::deque& asked = timesAsked(); const F64 now = LLTimer::getTotalSeconds(); while (!asked.empty() && asked.front().until < now) @@ -1831,21 +1834,37 @@ void ALScriptWorkspace::processLandStatReply(LLMessageSystem* msg, void** data) LLFloaterTopObjects::handle_land_reply(msg, data); return; } - const auto told = std::move(asked.front().told); - asked.pop_front(); ALScriptRegionUsage::times_t times; + std::vector named; const S32 count = msg->getNumberOfBlocksFast(_PREHASH_ReportData); for (S32 block = 0; block < count; ++block) { - LLUUID task; - F32 score = 0.f; + LLUUID task; + F32 score = 0.f; + std::string owner; msg->getUUIDFast(_PREHASH_ReportData, _PREHASH_TaskID, task, block); msg->getF32Fast(_PREHASH_ReportData, _PREHASH_Score, score, block); + msg->getStringFast(_PREHASH_ReportData, _PREHASH_OwnerName, owner, block); + named.push_back(std::move(owner)); if (task.notNull()) { times[task] = score; } } + std::vector waiting; + for (const TimeAsked& one : asked) + { + waiting.push_back(one.owner); + } + const LLFloaterTopObjects* top = LLFloaterReg::findTypedInstance("top_objects"); + const std::optional whose = ALScriptRegionUsage::answering(waiting, named, top && top->isInVisibleChain()); + if (!whose) + { + LLFloaterTopObjects::handle_land_reply(msg, data); + return; + } + const auto told = std::move(asked[*whose].told); + asked.erase(asked.begin() + static_cast(*whose)); if (told) { told(times); diff --git a/indra/newview/tests/alscriptregionusage_test.cpp b/indra/newview/tests/alscriptregionusage_test.cpp index ff717ee883..1dfdf93bd1 100644 --- a/indra/newview/tests/alscriptregionusage_test.cpp +++ b/indra/newview/tests/alscriptregionusage_test.cpp @@ -242,4 +242,22 @@ namespace tut landAsks[0].second(details); ensure("time kept through memory", usage.usageOf(id(1))->time == 0.125f); } + + template<> template<> + void alscriptregionusage_object::test<5>() + { + set_test_name("an answer of Top Scripts by owner is the oldest ask's whose owner its rows name; else the oldest's while Top Objects is closed, and Top Objects' while it is open"); + typedef std::optional Whose; + ensure_equals("a name in either form, lowered", ALScriptRegionUsage::ownerKey("Ann Resident "), std::string("ann")); + ensure_equals("a last name kept", ALScriptRegionUsage::ownerKey("Bo Smith"), std::string("bo.smith")); + ensure_equals("a username as it is", ALScriptRegionUsage::ownerKey("Bo.Smith"), std::string("bo.smith")); + const std::vector waiting = { "Ann Resident", "Bo Smith", "Ann Resident" }; + ensure("the oldest it names", ALScriptRegionUsage::answering(waiting, { "ann ", "ann " }, true) == Whose(0)); + ensure("not the oldest where it names a later one", ALScriptRegionUsage::answering(waiting, { "Bo Smith" }, true) == Whose(1)); + ensure("one it does not name, with Top Objects open: Top Objects'", !ALScriptRegionUsage::answering(waiting, { "Cy Resident" }, true)); + ensure("no rows, with Top Objects open: Top Objects'", !ALScriptRegionUsage::answering(waiting, {}, true)); + ensure("no rows, with it closed: the oldest's", ALScriptRegionUsage::answering(waiting, {}, false) == Whose(0)); + ensure("named otherwise, with it closed: the oldest's still", ALScriptRegionUsage::answering(waiting, { "Cy Resident" }, false) == Whose(0)); + ensure("none waiting: none", !ALScriptRegionUsage::answering({}, { "Ann Resident" }, false)); + } } From 03cfd2f08066d4bc103b70725bde7a08758893d4 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 19:07:07 -0400 Subject: [PATCH 28/41] LSL to SLua: a counting loop over a list's items walks the items A loop counting from 0 to below a list's length, whose counter is read as nothing but that list's items, becomes SLua's walk over the list, for _, item in l, each read the item by name. A loop inside another gets a name of its own, and where the script has a name _, the unread index gets a fresh one. A loop starting further in, or reading its counter as anything else, still counts. Co-Authored-By: Claude Opus 5.5 --- indra/alscript/allsltoslua.cpp | 96 ++++++++++++++++++++--- indra/alscript/tests/allsltoslua_test.cpp | 46 +++++++++-- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/indra/alscript/allsltoslua.cpp b/indra/alscript/allsltoslua.cpp index b1ee4634c7..a53a692968 100644 --- a/indra/alscript/allsltoslua.cpp +++ b/indra/alscript/allsltoslua.cpp @@ -386,6 +386,10 @@ namespace // Whether every read of a counter in a loop's body is an index that // ll is given (llIndex), which the loop may count from 1 for. bool onlyIndexes(LSLSymbol* var, LSLASTNode* body); + // Whether every read of a counter in a loop's body is of one list's + // item at it, as it is (listItemFits): the loop may walk the list's + // items instead (mEach). + bool onlyItemsOf(LSLSymbol* var, LSLSymbol* list, LSLASTNode* body); // The call's arguments for ll: its index arguments moved on by one. std::string llArgs(LSLFunctionExpression* e, U16 indexes); // `: type` for an LSL type, where types are written. @@ -510,6 +514,8 @@ namespace static std::vector appended(LSLASTNode* node, LSLSymbol*& var); // A name no other in the script has, nor SLua holds. std::string freshName(const std::string& base); + // Every name the script has, and those made fresh, in mTaken. + void takeNames(); // --- steps as statements ----------------------------------------------------- @@ -634,6 +640,11 @@ namespace boost::unordered_flat_set mFromOne; // Counters within a list's length, i < llGetListLength(l): the list. boost::unordered_flat_map mWithin; + // Counters whose loop walks their list's items instead: the item's + // name. A name for each depth of such loops, made once. + boost::unordered_flat_map mEach; + std::vector mItemNames; + std::string mUnread; boost::unordered_flat_map mListTypes; boost::unordered_flat_set mLoopOnly; boost::unordered_flat_set mBooleans; @@ -2580,20 +2591,46 @@ namespace : shift < 0 ? value(c.limit).text : bracketed(value(c.limit), ADD) + (shift > 0 ? " + 2" : " + 1"); } - line("for " + nameOf(c.id) + " = " + start + ", " + to + step + " do"); - if (up || down) - { - mNonNegative.insert(c.var); - } // Up by one, below a list's length: within the list. LSLExpression* limit_call = unwrapped(c.limit); + LSLExpression* listed = nullptr; if (up && c.step == 1 && c.check == OP_LESS && limit_call->getNodeSubType() == NODE_FUNCTION_EXPRESSION && std::string_view(static_cast(limit_call)->getIdentifier()->getName()) == "llGetListLength") { - if (LSLSymbol* list = wholeVariable(argumentAt(static_cast(limit_call), 0))) + listed = argumentAt(static_cast(limit_call), 0); + } + LSLSymbol* within = listed ? wholeVariable(listed) : nullptr; + // From its first item, the counter read as nothing but the list's + // items: Luau's walk over them, each by name. + const bool each = from_one && within && from == 0 && onlyItemsOf(c.var, within, f->getBody()); + if (each) + { + const size_t depth = mEach.size(); + if (depth == mItemNames.size()) { - mWithin[c.var] = list; + mItemNames.push_back(freshName("item")); } + // The index unread, as Luau writes one, unless the script has + // a name that would be hidden by it. + if (mUnread.empty()) + { + takeNames(); + mUnread = mTaken.contains("_") ? freshName("_") : std::string("_"); + } + line("for " + mUnread + ", " + mItemNames[depth] + " in " + lvalue(static_cast(unwrapped(listed))).text + " do"); + mEach[c.var] = mItemNames[depth]; + } + else + { + line("for " + nameOf(c.id) + " = " + start + ", " + to + step + " do"); + } + if (up || down) + { + mNonNegative.insert(c.var); + } + if (within) + { + mWithin[c.var] = within; } if (from_one) { @@ -2608,6 +2645,7 @@ namespace mNonNegative.erase(c.var); mFromOne.erase(c.var); mWithin.erase(c.var); + mEach.erase(c.var); line("end"); } @@ -2665,6 +2703,39 @@ namespace return any && only; } + bool Writer::onlyItemsOf(LSLSymbol* var, LSLSymbol* list, LSLASTNode* body) + { + bool any = false; + bool only = true; + walk(body, [&](LSLASTNode* node) { + if (!only || node->getNodeSubType() != NODE_LVALUE_EXPRESSION || + static_cast(node)->getIdentifier()->getSymbol() != var) + { + return; + } + any = true; + // As it is, the index of a library call reading an item of the + // list. + LSLASTNode* arg = node; + while (arg->getParent() && arg->getParent()->getNodeSubType() == NODE_PARENTHESIS_EXPRESSION) + { + arg = arg->getParent(); + } + LSLASTNode* args = arg->getParent(); + LSLASTNode* call = args ? args->getParent() : nullptr; + if (!call || args->getNodeType() != NODE_AST_NODE_LIST || call->getNodeSubType() != NODE_FUNCTION_EXPRESSION || + arg->getParentSlot() != 1) + { + only = false; + return; + } + auto* read = static_cast(call); + LSLSymbol* fn = read->getIdentifier()->getSymbol(); + only = fn && fn->getSubType() == SYM_BUILTIN && wholeVariable(argumentAt(read, 0)) == list && listItemFits(read); + }); + return any && only; + } + // --- truth values ---------------------------------------------------------------- namespace @@ -3824,7 +3895,7 @@ namespace return true; } - std::string Writer::freshName(const std::string& base) + void Writer::takeNames() { if (mTaken.empty()) { @@ -3836,6 +3907,11 @@ namespace } }); } + } + + std::string Writer::freshName(const std::string& base) + { + takeNames(); std::string name = base; for (int n = 2; mTaken.contains(name) || reservedName(name); ++n) { @@ -4151,7 +4227,9 @@ namespace LSLSymbol* counter = wholeVariable(index); const auto within = counter ? mWithin.find(counter) : mWithin.end(); const bool found = within != mWithin.end() && within->second == var; - const std::string item = name + "[" + at + "]"; + // And where its loop walks the list's items, the item by name. + const auto walked = found ? mEach.find(counter) : mEach.end(); + const std::string item = walked != mEach.end() ? walked->second : name + "[" + at + "]"; const Expr read = found ? Expr{ item } : Expr{ item + " or " + kind->second.empty, OR }; return as_is ? read : Expr{ "tostring(" + read.text + ")" }; } diff --git a/indra/alscript/tests/allsltoslua_test.cpp b/indra/alscript/tests/allsltoslua_test.cpp index c1b5f8309b..00aabdd4b3 100644 --- a/indra/alscript/tests/allsltoslua_test.cpp +++ b/indra/alscript/tests/allsltoslua_test.cpp @@ -384,7 +384,7 @@ namespace tut " integer f; for (f = 0; f < 3; ++f) total += f;\n" " llOwnerSay((string)(total + f));\n" "} }\n"); - ensure("up to a length: " + r.text, has(r, "for a = 1, #l do\n total += l[a]")); + ensure("up to a length, walking its items: " + r.text, has(r, "for _, item in l do\n total += item")); ensure("down by a step: " + r.text, has(r, "for b = 10, 0, -2 do")); ensure("up to and with: " + r.text, has(r, "for c = 1, n do")); ensure("a counter set in its body keeps the while: " + r.text, has(r, "while d < n do")); @@ -550,8 +550,8 @@ namespace tut " for (k = 0; k < 2; ++k) gReport += \"y\";\n" " llOwnerSay(out + seen + gReport + (string)outParts);\n" "} }\n"); - ensure("the outer loop's table, a name of its own: " + r.text, has(r, "local outParts2 = {}\n for i = 1, #names do")); - ensure("the pieces put in: " + r.text, has(r, "table.insert(outParts2, names[i] .. row)")); + ensure("the outer loop's table, a name of its own: " + r.text, has(r, "local outParts2 = {}\n for _, item in names do")); + ensure("the pieces put in: " + r.text, has(r, "table.insert(outParts2, item .. row)")); ensure("joined after: " + r.text, has(r, " end\n out ..= table.concat(outParts2)")); ensure("the inner loop's, declared in the outer: " + r.text, has(r, " local rowParts = {}\n for j = 0, 2 do\n table.insert(rowParts, tostring(j))")); @@ -878,7 +878,7 @@ namespace tut " integer n = 1;\n" " llOwnerSay(llList2String(gNames, 0) + llList2String(p, -1) + llList2String(keys, 1));\n" " llOwnerSay(llList2String(mixed, 2) + (string)llList2Integer(mixed, 0) + llList2String(gNames, n));\n" - " integer i; for (i = 0; i < llGetListLength(p); ++i) llOwnerSay(llList2String(p, i));\n" + " integer i; for (i = 0; i < llGetListLength(p); ++i) llOwnerSay(llList2String(p, i) + llGetSubString(\"abc\", i, i));\n" "} }\n"); ensure("from the start, empty past the end: " + r.text, has(r, "(gNames[1] or \"\")")); ensure("back from the end, through a function's list: " + r.text, has(r, "(p[#p] or \"\")")); @@ -886,7 +886,7 @@ namespace tut ensure("a float's text is not tostring's, and an index not known: llcompat: " + r.text, has(r, "llcompat.List2String(mixed, 2)") && has(r, "llcompat.List2String(gNames, n)")); ensure("mixed items for a number: llcompat: " + r.text, has(r, "llcompat.List2Integer(mixed, 0)")); - ensure("within its length, as it is, counting from 1: " + r.text, has(r, "for i = 1, #p do\n print(p[i])")); + ensure("within its length, as it is, counting from 1: " + r.text, has(r, "for i = 1, #p do\n print(p[i] .. ll.GetSubString(\"abc\", i, i))")); checksClean(r); } @@ -1051,4 +1051,40 @@ namespace tut ensure("typed: " + t.text, has(t, "local function say(loud: boolean, text: string)")); checksClean(t); } + + template<> template<> + void allsltoslua_object::test<34>() + { + set_test_name("a counting loop over a list's items, its counter read as nothing else, walks the items; from another start, or read otherwise, it counts"); + const ALLSLToSLua::Result r = convert("list gNames = [\"a\", \"b\"];\n" + "default { state_entry() {\n" + " list keys = [llGetOwner()];\n" + " integer i;\n" + " for (i = 0; i < llGetListLength(gNames); ++i) llOwnerSay(llList2String(gNames, i));\n" + " integer j;\n" + " for (j = 0; j < llGetListLength(gNames); ++j) {\n" + " integer k;\n" + " for (k = 0; k < llGetListLength(keys); ++k) llOwnerSay(llList2String(gNames, j) + llList2String(keys, k));\n" + " }\n" + " integer m;\n" + " for (m = 1; m < llGetListLength(gNames); ++m) llOwnerSay(llList2String(gNames, m));\n" + " integer n;\n" + " for (n = 0; n < llGetListLength(gNames); ++n) llOwnerSay(llList2String(gNames, n) + (string)n);\n" + "} }\n"); + ensure("walked: " + r.text, has(r, "for _, item in gNames do\n print(item)\n")); + ensure("one inside another, each its own name, a key's made text: " + r.text, + has(r, "for _, item2 in keys do\n print(item .. tostring(item2))")); + ensure("from the second: counted: " + r.text, has(r, "for m = 2, #gNames do\n print(gNames[m])")); + ensure("its counter read as a number too: counted: " + r.text, has(r, "for n = 0, #gNames - 1 do")); + checksClean(r); + + const ALLSLToSLua::Result named = convert("list gNames = [\"a\"];\n" + "integer _ = 2;\n" + "default { state_entry() {\n" + " integer i;\n" + " for (i = 0; i < llGetListLength(gNames); ++i) llOwnerSay(llList2String(gNames, i) + (string)_);\n" + "} }\n"); + ensure("an index that would hide the script's _ named afresh: " + named.text, has(named, "for _2, item in gNames do")); + checksClean(named); + } } From df5759d0fe1547e19ff03f193cfcb063c39b2883 Mon Sep 17 00:00:00 2001 From: Rye Date: Sat, 3 Oct 2026 19:07:17 -0400 Subject: [PATCH 29/41] A snippets notecard can be followed, its snippets offered as it stands Dropping a snippets notecard on the Snippets tab copies its snippets into one's own. A notecard can now be followed instead, dropped on the tab's Follow box: its snippets are offered beside the viewer's and one's own for as long as it is followed, as the notecard stands whoever changes it -- a team's notecard, kept up to date by one of them. Each is in the language it says, one saying none in both. The list shows them after one's own, quiet and read-only, to copy to change; they are never written to one's own file or notecard. A notecard that is not snippets gives none and says why in the box's tip. Clear stops following it, and Cancel puts back the one followed before. Following a notecard is the vimrc notecard's way, taken out into a class of its own that both use: the item in a per-account setting, the text kept on disk with the asset it was read from, and fetched again only when the item has a new asset. Co-Authored-By: Claude Opus 5.5 --- indra/newview/CMakeLists.txt | 4 + indra/newview/alfloaterscriptpreferences.cpp | 148 +++++++++---- indra/newview/alfloaterscriptpreferences.h | 10 +- indra/newview/alfloaterscriptstudio.cpp | 3 + indra/newview/alfollowednotecard.cpp | 200 ++++++++++++++++++ indra/newview/alfollowednotecard.h | 100 +++++++++ indra/newview/alscriptsnippets.cpp | 37 +++- indra/newview/alscriptsnippets.h | 22 +- .../newview/alscriptstudiosnippetnotecard.cpp | 83 ++++++++ indra/newview/alscriptstudiosnippetnotecard.h | 78 +++++++ indra/newview/alscriptstudiovimrc.cpp | 134 ++---------- indra/newview/alscriptstudiovimrc.h | 34 +-- .../settings_per_account_alchemy.xml | 11 + .../xui/en/floater_script_studio_prefs.xml | 38 +++- .../newview/skins/default/xui/en/strings.xml | 2 + indra/newview/tests/alscriptsnippets_test.cpp | 35 +++ 16 files changed, 736 insertions(+), 203 deletions(-) create mode 100644 indra/newview/alfollowednotecard.cpp create mode 100644 indra/newview/alfollowednotecard.h create mode 100644 indra/newview/alscriptstudiosnippetnotecard.cpp create mode 100644 indra/newview/alscriptstudiosnippetnotecard.h diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index c78d5314fb..438a70ab1a 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -230,6 +230,8 @@ if(AL_BUILD_VIEWER) alscriptstudiosaving.cpp alscriptstudioviewer.cpp alscriptstudiovim.cpp + alfollowednotecard.cpp + alscriptstudiosnippetnotecard.cpp alscriptstudiovimrc.cpp alscriptstudioweighing.cpp alscriptstudiowords.cpp @@ -1074,6 +1076,8 @@ if(AL_BUILD_VIEWER) alrecoverykeeper.h alscriptstudiosaving.h alscriptstudiovim.h + alfollowednotecard.h + alscriptstudiosnippetnotecard.h alscriptstudiovimrc.h alscriptstudioweighing.h alscriptstudiowords.h diff --git a/indra/newview/alfloaterscriptpreferences.cpp b/indra/newview/alfloaterscriptpreferences.cpp index a64678d237..a795105f6e 100644 --- a/indra/newview/alfloaterscriptpreferences.cpp +++ b/indra/newview/alfloaterscriptpreferences.cpp @@ -32,6 +32,7 @@ #include "alcodeeditor.h" #include "alscriptstudio.h" #include "alscriptanalysis.h" +#include "alscriptstudiosnippetnotecard.h" #include "alscriptstudiovimrc.h" #include "alscriptstudiowords.h" #include "alfontfield.h" @@ -65,7 +66,9 @@ #include "llviewerassettype.h" #include +#include #include +#include namespace { @@ -103,6 +106,16 @@ namespace // How long after the last keystroke an edited snippet is written. const F32 SNIPPET_SETTLE = 0.5f; + // The notecard of the agent's own that a drop carries, a link as what + // it links to -- the notecard's asset is what changes as it is edited; + // null where it carries anything else. + const LLViewerInventoryItem* droppedNotecard(EDragAndDropType cargo_type, void* cargo_data) + { + const LLInventoryItem* item = static_cast(cargo_data); + const LLViewerInventoryItem* held = item ? gInventory.getItem(item->getLinkedUUID()) : nullptr; + return cargo_type == DAD_NOTECARD && held && held->getType() == LLAssetType::AT_NOTECARD ? held : nullptr; + } + const S32 SWATCH_ROW = 24; const S32 SWATCH_WIDTH = 48; const S32 SWATCH_HEIGHT = 20; @@ -150,6 +163,14 @@ bool ALFloaterScriptPreferences::postBuild() [](LLUICtrl*, const LLSD&) { ALScriptStudioVimrc::instance().useNotecard(LLUUID::null); }); mVimrcChanged = ALScriptStudioVimrc::instance().onChanged([this]() { refreshVimrc(); }); refreshVimrc(); + mSnippetNotecard = getChild("snippet_notecard"); + getChild("snippet_notecard_clear")->setCommitCallback( + [](LLUICtrl*, const LLSD&) { ALScriptStudioSnippetNotecard::instance().useNotecard(LLUUID::null); }); + mSnippetNotecardChanged = ALScriptStudioSnippetNotecard::instance().onChanged([this]() { + refreshSnippetNotecard(); + fillSnippets(false); + }); + refreshSnippetNotecard(); getChild("scripting_settings")->setCommitCallback([](LLUICtrl*, const LLSD&) { LLFloaterReg::showInstance("scripting_settings"); }); getChild("snippet_xml")->setCommitCallback([this](LLUICtrl*, const LLSD&) { flushSnippets(); @@ -459,8 +480,9 @@ void ALFloaterScriptPreferences::remember() mWasSettings[setting] = control->getValue(); } } - // The account's, as the notecard is. - mWasVimrc = ALScriptStudioVimrc::instance().notecard().asString(); + // The account's, as the notecards are. + mWasVimrc = ALScriptStudioVimrc::instance().notecard().asString(); + mWasSnippetNotecard = ALScriptStudioSnippetNotecard::instance().notecard().asString(); } void ALFloaterScriptPreferences::revert() @@ -489,6 +511,10 @@ void ALFloaterScriptPreferences::revert() { ALScriptStudioVimrc::instance().useNotecard(LLUUID(mWasVimrc)); } + if (ALScriptStudioSnippetNotecard::instance().notecard().asString() != mWasSnippetNotecard) + { + ALScriptStudioSnippetNotecard::instance().useNotecard(LLUUID(mWasSnippetNotecard)); + } // The snippets as they were, where they were changed here. for (bool lua : { false, true }) { @@ -504,7 +530,8 @@ void ALFloaterScriptPreferences::revert() bool ALFloaterScriptPreferences::changed() const { - if (mSnippetsUnsaved || ALScriptStudioVimrc::instance().notecard().asString() != mWasVimrc) + if (mSnippetsUnsaved || ALScriptStudioVimrc::instance().notecard().asString() != mWasVimrc || + ALScriptStudioSnippetNotecard::instance().notecard().asString() != mWasSnippetNotecard) { return true; } @@ -871,50 +898,56 @@ void ALFloaterScriptPreferences::refreshVimrc() where->setText(!vimrc.error().empty() ? vimrc.error() : getString(notecard ? "VimrcFromNotecard" : "VimrcFromFile", args)); } +void ALFloaterScriptPreferences::refreshSnippetNotecard() +{ + const ALScriptStudioSnippetNotecard& followed = ALScriptStudioSnippetNotecard::instance(); + const bool notecard = followed.notecard().notNull(); + const std::string name = followed.notecardName(); + // Empty with none: the box's label asks for one. + mSnippetNotecard->setText(!notecard ? std::string() : name.empty() ? getString("SnippetNotecardUnknown") : name); + mSnippetNotecard->setToolTip(!followed.error().empty() ? followed.error() : getString("SnippetNotecardTip")); + getChildView("snippet_notecard_clear")->setEnabled(notecard); +} + bool ALFloaterScriptPreferences::handleDragAndDrop(S32 x, S32 y, MASK mask, bool drop, EDragAndDropType cargo_type, void* cargo_data, EAcceptance* accept, std::string& tooltip_msg) { - S32 local_x = 0; - S32 local_y = 0; - if (mVimrcNotecard && mVimrcNotecard->isInVisibleChain() && localPointToOtherView(x, y, &local_x, &local_y, mVimrcNotecard) && - mVimrcNotecard->pointInView(local_x, local_y)) - { - // A notecard of the agent's own, a link as what it links to: the - // notecard's asset is what changes as it is edited. - const LLInventoryItem* item = static_cast(cargo_data); - const LLViewerInventoryItem* held = item ? gInventory.getItem(item->getLinkedUUID()) : nullptr; - if (cargo_type != DAD_NOTECARD || !held || held->getType() != LLAssetType::AT_NOTECARD) + // Which box it is over, and what a notecard dropped there does; each + // takes a notecard of the agent's own alone. + const auto over = [this, x, y](LLView* view) { + S32 local_x = 0; + S32 local_y = 0; + return view && view->isInVisibleChain() && localPointToOtherView(x, y, &local_x, &local_y, view) && view->pointInView(local_x, local_y); + }; + const auto take = [&](const char* only, const std::function& use) { + const LLViewerInventoryItem* held = droppedNotecard(cargo_type, cargo_data); + if (!held) { *accept = ACCEPT_NO; - tooltip_msg = getString("VimrcOnlyNotecards"); + tooltip_msg = getString(only); return true; } *accept = ACCEPT_YES_SINGLE; if (drop) { - ALScriptStudioVimrc::instance().useNotecard(held->getUUID()); + use(*held); } return true; + }; + if (over(mVimrcNotecard)) + { + return take("VimrcOnlyNotecards", [](const LLViewerInventoryItem& held) { ALScriptStudioVimrc::instance().useNotecard(held.getUUID()); }); } - if (mSnippetList && mSnippetList->isInVisibleChain() && localPointToOtherView(x, y, &local_x, &local_y, mSnippetList) && - mSnippetList->pointInView(local_x, local_y)) + if (over(mSnippetNotecard)) { - // A notecard of the agent's own, whose snippets come in beside - // theirs. - const LLInventoryItem* item = static_cast(cargo_data); - const LLViewerInventoryItem* held = item ? gInventory.getItem(item->getLinkedUUID()) : nullptr; - if (cargo_type != DAD_NOTECARD || !held || held->getType() != LLAssetType::AT_NOTECARD) - { - *accept = ACCEPT_NO; - tooltip_msg = getString("SnippetsOnlyNotecards"); - return true; - } - *accept = ACCEPT_YES_SINGLE; - if (drop) - { - addSnippetsFrom(*held); - } - return true; + // Followed: its snippets offered as it stands. + return take("SnippetsFollowOnlyNotecards", + [](const LLViewerInventoryItem& held) { ALScriptStudioSnippetNotecard::instance().useNotecard(held.getUUID()); }); + } + if (over(mSnippetList)) + { + // Its snippets come in beside the scripter's own. + return take("SnippetsOnlyNotecards", [this](const LLViewerInventoryItem& held) { addSnippetsFrom(held); }); } return LLFloater::handleDragAndDrop(x, y, mask, drop, cargo_type, cargo_data, accept, tooltip_msg); } @@ -1093,7 +1126,7 @@ bool ALFloaterScriptPreferences::snippetLua() const S32 ALFloaterScriptPreferences::chosenOwnSnippet() const { // A row's value says whose it is and where, as the list compares - // values by their words: "own:2", "builtin:5". + // values by their words: "own:2", "followed:0", "builtin:5". const LLScrollListItem* item = mSnippetList->getFirstSelected(); const std::string value = item ? item->getValue().asString() : std::string(); if (value.compare(0, 4, "own:") != 0) @@ -1130,17 +1163,22 @@ void ALFloaterScriptPreferences::fillSnippets(bool reread) const S32 scrolled = mSnippetList->getScrollPos(); mSnippetList->deleteAllItems(); const LLUIColor& theirs = ALSurface::quiet(); - const auto add = [this, &theirs](const ALScriptSnippets::Snippet& one, bool own, S32 index) { + LLStringUtil::format_map_t args; + args["[NAME]"] = ALScriptStudioSnippetNotecard::instance().notecardName(); + const std::string followed = getString("SnippetFollowed", args); + const auto add = [this, &theirs, &followed](const ALScriptSnippets::Snippet& one, const char* whose, S32 index) { + const bool own = std::string_view(whose) == "own"; LLSD row; - row["value"] = (own ? "own:" : "builtin:") + std::to_string(index); + row["value"] = std::string(whose) + ":" + std::to_string(index); row["columns"][0]["column"] = "name"; row["columns"][0]["value"] = one.name.empty() ? getString("SnippetUnnamed") : one.name; - row["columns"][0]["tool_tip"] = own ? one.detail : getString("SnippetBuiltin"); + row["columns"][0]["tool_tip"] = own ? one.detail : one.followed ? followed : getString("SnippetBuiltin"); row["columns"][1]["column"] = "prefix"; row["columns"][1]["value"] = one.prefix; if (!own) { - // The viewer's, quieter than the scripter's own. + // The viewer's and the notecard's, quieter than the scripter's + // own. row["columns"][0]["color"] = theirs.get().getValue(); row["columns"][1]["color"] = theirs.get().getValue(); } @@ -1148,14 +1186,19 @@ void ALFloaterScriptPreferences::fillSnippets(bool reread) }; for (size_t i = 0; i < mOwnSnippets.size(); ++i) { - add(mOwnSnippets[i], true, static_cast(i)); + add(mOwnSnippets[i], "own", static_cast(i)); + } + const std::vector& notecard = ALScriptSnippets::followed(lua); + for (size_t i = 0; i < notecard.size(); ++i) + { + add(notecard[i], "followed", static_cast(i)); } S32 builtin = 0; for (const ALScriptSnippets::Snippet& one : ALScriptSnippets::all(lua)) { if (one.builtin) { - add(one, false, builtin++); + add(one, "builtin", builtin++); } } if (chosen.isString()) @@ -1179,14 +1222,24 @@ void ALFloaterScriptPreferences::showSnippet() } else if (item) { - // One of the viewer's, by its place among them. - S32 builtin = 0; - for (const ALScriptSnippets::Snippet& one : ALScriptSnippets::all(snippetLua())) + // The followed notecard's, or the viewer's, by its place among them. + const std::string value = item->getValue().asString(); + const std::vector& notecard = ALScriptSnippets::followed(snippetLua()); + if (value.compare(0, 9, "followed:") == 0) { - if (one.builtin && builtin++ == atoi(item->getValue().asString().c_str() + 8)) + const S32 index = atoi(value.c_str() + 9); + shown = index >= 0 && index < static_cast(notecard.size()) ? ¬ecard[static_cast(index)] : nullptr; + } + else if (value.compare(0, 8, "builtin:") == 0) + { + S32 builtin = 0; + for (const ALScriptSnippets::Snippet& one : ALScriptSnippets::all(snippetLua())) { - shown = &one; - break; + if (one.builtin && builtin++ == atoi(value.c_str() + 8)) + { + shown = &one; + break; + } } } } @@ -1200,7 +1253,8 @@ void ALFloaterScriptPreferences::showSnippet() mSnippetBody->setText(body); } mSettingSnippet = false; - // The scripter's own are edited here; the viewer's are read, and copied. + // The scripter's own are edited here; the viewer's and the notecard's + // are read, and copied. const bool editable = own >= 0; mSnippetName->setEnabled(editable); mSnippetPrefix->setEnabled(editable); diff --git a/indra/newview/alfloaterscriptpreferences.h b/indra/newview/alfloaterscriptpreferences.h index 811258c7ba..2f33254dc6 100644 --- a/indra/newview/alfloaterscriptpreferences.h +++ b/indra/newview/alfloaterscriptpreferences.h @@ -66,7 +66,8 @@ class ALFloaterScriptPreferences final : public LLFloater // OK and Cancel: where something was changed, asked first whether to // keep it, put it back, or stay. bool canClose() override; - // A notecard dropped on the vimrc's box. + // A notecard dropped on the vimrc's box, the snippet list, or the box + // for the snippet notecard followed. bool handleDragAndDrop(S32 x, S32 y, MASK mask, bool drop, EDragAndDropType cargo_type, void* cargo_data, EAcceptance* accept, std::string& tooltip_msg) override; @@ -146,6 +147,9 @@ class ALFloaterScriptPreferences final : public LLFloater // The vimrc's box: the notecard's name, or none, and where the vimrc // is read from or why it could not be. void refreshVimrc(); + // The followed snippet notecard's box: its name, or none, and why its + // snippets could not be read, where they could not. + void refreshSnippetNotecard(); LLTabContainer* mTabs = nullptr; LLComboBox* mThemes = nullptr; @@ -213,6 +217,10 @@ class ALFloaterScriptPreferences final : public LLFloater // The notecard that was the vimrc when the window opened, for Cancel. std::string mWasVimrc; boost::signals2::scoped_connection mVimrcChanged; + LLLineEditor* mSnippetNotecard = nullptr; + // The snippet notecard followed when the window opened, for Cancel. + std::string mWasSnippetNotecard; + boost::signals2::scoped_connection mSnippetNotecardChanged; LLScrollListCtrl* mLintsLSL = nullptr; LLScrollListCtrl* mLintsLuau = nullptr; // The settings the enabling follows, by their own signals, so that it diff --git a/indra/newview/alfloaterscriptstudio.cpp b/indra/newview/alfloaterscriptstudio.cpp index 9d243cf752..2c1c043e03 100644 --- a/indra/newview/alfloaterscriptstudio.cpp +++ b/indra/newview/alfloaterscriptstudio.cpp @@ -48,6 +48,7 @@ #include "alscriptstudiofileio.h" #include "alscriptstudioglue.h" #include "alscriptstudioplaces.h" +#include "alscriptstudiosnippetnotecard.h" #include "alscriptstudiovimrc.h" #include "alscriptworkspace.h" #include "alemptystate.h" @@ -1472,6 +1473,8 @@ void ALFloaterScriptStudio::draw() { ALScriptStudioVimrc::instance().check(); } + // The followed snippet notecard's, fetched again where it is saved. + ALScriptStudioSnippetNotecard::instance().check(); mSearchPane->pump(); mNavigation.pumpSettle(); refreshUndoLabels(); diff --git a/indra/newview/alfollowednotecard.cpp b/indra/newview/alfollowednotecard.cpp new file mode 100644 index 0000000000..d1c63ae225 --- /dev/null +++ b/indra/newview/alfollowednotecard.cpp @@ -0,0 +1,200 @@ +/** + * @file alfollowednotecard.cpp + * @brief A notecard in inventory followed for its text, kept on disk while its asset is unchanged. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alfollowednotecard.h" + +#include "alscriptworkspace.h" +#include "llfile.h" +#include "llinventorymodel.h" +#include "llsdserialize.h" +#include "lltrans.h" +#include "llviewercontrol.h" +#include "llviewerinventory.h" + +ALFollowedNotecard::ALFollowedNotecard(std::string setting, std::string cache_file, std::string not_notecard) + : mSetting(std::move(setting)), mCacheFile(std::move(cache_file)), mNotNotecard(std::move(not_notecard)) +{ + if (LLControlVariable* control = gSavedPerAccountSettings.getControl(mSetting)) + { + mSettingChanged = control->getSignal()->connect([this](LLControlVariable*, const LLSD&, const LLSD&) { + mMoving = true; + refresh(); + mMoving = false; + mChanged(true); + }); + } + refresh(); +} + +ALFollowedNotecard::~ALFollowedNotecard() = default; + +LLUUID ALFollowedNotecard::item() const +{ + const LLControlVariable* control = gSavedPerAccountSettings.getControl(mSetting); + return control ? LLUUID(control->getValue().asString()) : LLUUID::null; +} + +std::string ALFollowedNotecard::name() const +{ + const LLUUID followed = item(); + if (followed.isNull()) + { + return std::string(); + } + if (const LLViewerInventoryItem* held = gInventory.getItem(followed)) + { + return held->getName(); + } + return mCacheItem == followed ? mCacheName : std::string(); +} + +void ALFollowedNotecard::use(const LLUUID& followed) +{ + gSavedPerAccountSettings.setString(mSetting, followed.isNull() ? std::string() : followed.asString()); +} + +void ALFollowedNotecard::check() +{ + const LLUUID followed = item(); + if (followed.isNull()) + { + return; + } + const LLViewerInventoryItem* held = gInventory.getItem(followed); + if (held && (mCacheItem != followed || held->getAssetUUID() != mCacheAsset) && held->getAssetUUID() != mFetching) + { + refresh(); + } +} + +void ALFollowedNotecard::refresh() +{ + const LLUUID followed = item(); + if (followed.isNull()) + { + take(std::string(), std::string()); + return; + } + loadCache(); + const bool kept = mCacheItem == followed; + const LLViewerInventoryItem* held = gInventory.getItem(followed); + // The copy while it is this notecard's: the notecard as it stands + // where the item's asset is the one kept, or the last known while + // inventory has not come as far as the item; standing in, where the + // asset is another, until that comes. + take(kept ? mCacheText : std::string(), std::string()); + if (held && (!kept || held->getAssetUUID() != mCacheAsset)) + { + fetch(followed); + } +} + +void ALFollowedNotecard::fetch(const LLUUID& followed) +{ + const LLViewerInventoryItem* held = gInventory.getItem(followed); + if (!held || held->getAssetUUID() == mFetching) + { + return; + } + mFetching = held->getAssetUUID(); + std::weak_ptr alive = mAlive; + ALScriptWorkspace::getInstance()->load(ALScriptRef(LLUUID::null, followed), [this, alive, followed](const ALScriptLoaded& loaded) { + if (alive.expired()) + { + return; + } + mFetching.setNull(); + if (item() != followed) + { + // Another since. + return; + } + if (!loaded.error.empty() || !loaded.notecard) + { + // What was there stays; why it is not the notecard is said. + take(mText, loaded.error.empty() ? LLTrans::getString(mNotNotecard) : loaded.error); + return; + } + mCacheItem = followed; + mCacheAsset = loaded.assetId; + mCacheName = loaded.name; + mCacheText = loaded.text; + saveCache(); + take(loaded.text, std::string()); + }); +} + +std::string ALFollowedNotecard::cachePath() const +{ + return gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, mCacheFile); +} + +void ALFollowedNotecard::loadCache() +{ + if (mCacheLoaded) + { + return; + } + mCacheLoaded = true; + llifstream in(cachePath()); + LLSD kept; + if (!in.is_open() || LLSDSerialize::fromXML(kept, in) <= 0 || !kept.isMap()) + { + return; + } + mCacheItem = kept["item"].asUUID(); + mCacheAsset = kept["asset"].asUUID(); + mCacheName = kept["name"].asString(); + mCacheText = kept["text"].asString(); +} + +void ALFollowedNotecard::saveCache() +{ + LLSD kept; + kept["item"] = mCacheItem; + kept["asset"] = mCacheAsset; + kept["name"] = mCacheName; + kept["text"] = mCacheText; + llofstream out(cachePath()); + if (out.is_open()) + { + LLSDSerialize::toPrettyXML(kept, out); + } +} + +void ALFollowedNotecard::take(const std::string& text, const std::string& error) +{ + if (text == mText && error == mError) + { + return; + } + mText = text; + mError = error; + if (!mMoving) + { + mChanged(false); + } +} diff --git a/indra/newview/alfollowednotecard.h b/indra/newview/alfollowednotecard.h new file mode 100644 index 0000000000..cac88bd643 --- /dev/null +++ b/indra/newview/alfollowednotecard.h @@ -0,0 +1,100 @@ +/** + * @file alfollowednotecard.h + * @brief A notecard in inventory followed for its text, kept on disk while its asset is unchanged. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "lluuid.h" + +#include + +#include +#include + +// A notecard in inventory whose text something follows -- the vimrc, +// snippets -- by its item, which a per-account setting holds, since the +// notecard is the account's. +// +// Its text is kept on disk with the asset it was read from. An asset never +// changes: an edit to the notecard makes a new one and gives the item its +// id, so while the item's asset is the one kept the copy is the notecard as +// it stands, and it is read from disk rather than fetched again -- at once, +// before inventory has so much as loaded. An item that has moved on to +// another asset is fetched, the copy standing in until the new text comes. +class ALFollowedNotecard +{ +public: + // The per-account setting holding the item; the file, in the account's + // folder, the copy is kept in; and the string said where the item is + // not a notecard that reads, by its name in strings.xml. + ALFollowedNotecard(std::string setting, std::string cache_file, std::string not_notecard); + ~ALFollowedNotecard(); + + // The notecard followed, null where none is; its name, as last seen. + LLUUID item() const; + std::string name() const; + // Its text as last read, and why it could not be read the last time it + // was tried, empty where it was. + const std::string& text() const { return mText; } + const std::string& error() const { return mError; } + // Another notecard followed, or none with null. + void use(const LLUUID& item); + + // Fetched again where the item has another asset now -- saved since, + // here or elsewhere -- or has come into inventory with one. + void check(); + + // Said whenever the text or the error changes; `moved` where it is + // another notecard, or none, whatever its text. + typedef boost::signals2::signal changed_signal_t; + boost::signals2::connection onChanged(const changed_signal_t::slot_type& slot) { return mChanged.connect(slot); } + +private: + void refresh(); + void fetch(const LLUUID& item); + std::string cachePath() const; + void loadCache(); + void saveCache(); + void take(const std::string& text, const std::string& error); + + const std::string mSetting; + const std::string mCacheFile; + const std::string mNotNotecard; + std::string mText; + std::string mError; + // The copy kept on disk: whose, from which asset, its name and its + // text. + bool mCacheLoaded = false; + LLUUID mCacheItem; + LLUUID mCacheAsset; + std::string mCacheName; + std::string mCacheText; + // The asset a fetch is on its way for, so that another is not asked. + LLUUID mFetching; + // While another notecard is taken up: said once, as moved. + bool mMoving = false; + boost::signals2::scoped_connection mSettingChanged; + changed_signal_t mChanged; + // Whether this is still here, for what a fetch calls back. + std::shared_ptr mAlive = std::make_shared(true); +}; diff --git a/indra/newview/alscriptsnippets.cpp b/indra/newview/alscriptsnippets.cpp index 4bd42cc93c..be951780d6 100644 --- a/indra/newview/alscriptsnippets.cpp +++ b/indra/newview/alscriptsnippets.cpp @@ -41,6 +41,7 @@ namespace ALScriptSnippets { std::vector sAll[2]; bool sLoaded[2] = { false, false }; + std::vector sFollowed[2]; std::string fileName(bool lua) { @@ -91,7 +92,7 @@ namespace ALScriptSnippets { // One without a name or a body is one being written, and is // kept in the tab rather than in the file. - if (one.builtin || one.name.empty() || one.body.empty()) + if (one.builtin || one.followed || one.name.empty() || one.body.empty()) { continue; } @@ -220,8 +221,9 @@ namespace ALScriptSnippets if (there == own.end()) { own.push_back(one); - own.back().name = name; - own.back().builtin = false; + own.back().name = name; + own.back().builtin = false; + own.back().followed = false; ++(n == 1 ? merged.added : merged.renamed); break; } @@ -243,11 +245,40 @@ namespace ALScriptSnippets sLoaded[lua ? 1 : 0] = true; out.clear(); readFrom(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, fileName(lua)), true, out); + const std::vector& notecard = sFollowed[lua ? 1 : 0]; + out.insert(out.end(), notecard.begin(), notecard.end()); readFrom(path(lua), false, out); } return out; } + bool follow(const std::string& notecard_text) + { + std::vector lsl, slua, unused; + if (!notecard_text.empty() && + (!readNotecard(notecard_text, false, lsl, unused) || !readNotecard(notecard_text, true, unused, slua))) + { + return false; + } + for (std::vector* one : { &lsl, &slua }) + { + for (Snippet& snippet : *one) + { + snippet.followed = true; + } + } + sFollowed[0] = std::move(lsl); + sFollowed[1] = std::move(slua); + forget(false); + forget(true); + return true; + } + + const std::vector& followed(bool lua) + { + return sFollowed[lua ? 1 : 0]; + } + std::vector own(bool lua) { std::vector out; diff --git a/indra/newview/alscriptsnippets.h b/indra/newview/alscriptsnippets.h index 407441be6a..acb8519d18 100644 --- a/indra/newview/alscriptsnippets.h +++ b/indra/newview/alscriptsnippets.h @@ -28,9 +28,10 @@ #include // What completion and Insert > Snippet offer: the viewer's snippets, from -// app_settings/snippets, and the scripter's own, from the same folder under -// the settings folder, which the preferences' Snippets tab edits and a -// scripter may also edit as XML. Each file is an LLSD array of maps with a +// app_settings/snippets; those of a notecard the scripter follows, as it +// stands; and the scripter's own, from the same folder under the settings +// folder, which the preferences' Snippets tab edits and a scripter may also +// edit as XML. Each file is an LLSD array of maps with a // name, the prefix completion offers it under, a line of detail, and the // body, where ${1:text}, ${2} and $1 are the places Tab goes through and $0 // is where the caret ends. @@ -44,11 +45,22 @@ namespace ALScriptSnippets std::string body; // The viewer's, which a scripter copies to change. bool builtin = false; + // The followed notecard's, which a scripter copies to change, or + // changes in the notecard. + bool followed = false; }; - // Every snippet of a language, the viewer's then the scripter's; read - // once, and again after either file changes. + // Every snippet of a language, the viewer's, the followed notecard's, + // then the scripter's; read once, and again after either file or the + // notecard changes. const std::vector& all(bool lua); + // The notecard followed, by its text, its snippets taken in place of + // those it had -- each in the language it says, one saying none in + // both; none with empty text. False, with none taken, where the text + // is not snippets. + bool follow(const std::string& notecard_text); + // The followed notecard's alone. + const std::vector& followed(bool lua); // The scripter's own alone, as their file holds them. std::vector own(bool lua); // The scripter's own written back, and offered from then on. False diff --git a/indra/newview/alscriptstudiosnippetnotecard.cpp b/indra/newview/alscriptstudiosnippetnotecard.cpp new file mode 100644 index 0000000000..14d551b7ef --- /dev/null +++ b/indra/newview/alscriptstudiosnippetnotecard.cpp @@ -0,0 +1,83 @@ +/** + * @file alscriptstudiosnippetnotecard.cpp + * @brief The snippet notecard a scripter follows: its snippets offered beside their own, as it stands. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alscriptstudiosnippetnotecard.h" + +#include "alfollowednotecard.h" +#include "alscriptsnippets.h" +#include "lltrans.h" + +ALScriptStudioSnippetNotecard::~ALScriptStudioSnippetNotecard() = default; + +ALScriptStudioSnippetNotecard::ALScriptStudioSnippetNotecard() + : mNotecard(std::make_unique(SETTING, "script_studio_snippet_notecard.xml", "SnippetsFollowedNotNotecard")) +{ + mNotecardChanged = mNotecard->onChanged([this](bool) { + take(); + mChanged(); + }); + take(); +} + +LLUUID ALScriptStudioSnippetNotecard::notecard() const +{ + return mNotecard->item(); +} + +std::string ALScriptStudioSnippetNotecard::notecardName() const +{ + return mNotecard->name(); +} + +void ALScriptStudioSnippetNotecard::useNotecard(const LLUUID& item) +{ + mNotecard->use(item); +} + +void ALScriptStudioSnippetNotecard::check() +{ + if (mSinceCheck.getElapsedTimeF32() < 1.f) + { + return; + } + mSinceCheck.reset(); + mNotecard->check(); +} + +void ALScriptStudioSnippetNotecard::take() +{ + // Text that is not snippets gives none, and says so: what is offered + // is what the notecard holds. + mError = mNotecard->error(); + if (!ALScriptSnippets::follow(mNotecard->text())) + { + ALScriptSnippets::follow(std::string()); + if (mError.empty()) + { + mError = LLTrans::getString("SnippetsFollowedNotSnippets"); + } + } +} diff --git a/indra/newview/alscriptstudiosnippetnotecard.h b/indra/newview/alscriptstudiosnippetnotecard.h new file mode 100644 index 0000000000..859aa46e74 --- /dev/null +++ b/indra/newview/alscriptstudiosnippetnotecard.h @@ -0,0 +1,78 @@ +/** + * @file alscriptstudiosnippetnotecard.h + * @brief The snippet notecard a scripter follows: its snippets offered beside their own, as it stands. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llsingleton.h" +#include "lltimer.h" +#include "lluuid.h" + +#include + +#include +#include + +class ALFollowedNotecard; + +// The snippet notecard a scripter follows, dropped on the box for it in +// the studio's preferences: its snippets offered beside the viewer's and +// their own (ALScriptSnippets::follow) as the notecard stands, whoever +// changes it -- a team's notecard, kept up to date by one of them. Followed +// by ALFollowedNotecard, which keeps its text on disk with the asset it +// was read from. +class ALScriptStudioSnippetNotecard final : public LLSingleton +{ + LLSINGLETON(ALScriptStudioSnippetNotecard); + ~ALScriptStudioSnippetNotecard() override; + +public: + // The per-account setting that holds the notecard's item. + static constexpr const char* SETTING = "ALScriptStudioSnippetsNotecard"; + + // The notecard followed, null where none is; its name, as last seen; + // and why its snippets could not be read the last time they were + // tried, empty where they were. + LLUUID notecard() const; + std::string notecardName() const; + const std::string& error() const { return mError; } + // The notecard dropped on the box, or null for none. + void useNotecard(const LLUUID& item); + + // Said whenever its snippets, or which notecard it is, change. + typedef boost::signals2::signal changed_signal_t; + boost::signals2::connection onChanged(const changed_signal_t::slot_type& slot) { return mChanged.connect(slot); } + + // The notecard fetched where its item has another asset now. At most + // once a second, for a caller each frame. + void check(); + +private: + void take(); + + std::string mError; + LLTimer mSinceCheck; + std::unique_ptr mNotecard; + boost::signals2::scoped_connection mNotecardChanged; + changed_signal_t mChanged; +}; diff --git a/indra/newview/alscriptstudiovimrc.cpp b/indra/newview/alscriptstudiovimrc.cpp index 4078504b5f..6d1144db61 100644 --- a/indra/newview/alscriptstudiovimrc.cpp +++ b/indra/newview/alscriptstudiovimrc.cpp @@ -27,14 +27,10 @@ #include "alscriptstudiovimrc.h" #include "alfilewrite.h" -#include "alscriptworkspace.h" +#include "alfollowednotecard.h" #include "alwatchedfile.h" #include "llfile.h" -#include "llinventorymodel.h" -#include "llsdserialize.h" #include "lltrans.h" -#include "llviewercontrol.h" -#include "llviewerinventory.h" namespace { @@ -45,15 +41,16 @@ namespace ALScriptStudioVimrc::~ALScriptStudioVimrc() = default; ALScriptStudioVimrc::ALScriptStudioVimrc() + : mNotecard(std::make_unique(SETTING, "script_studio_vimrc.xml", "VimrcNotNotecard")) { - if (LLControlVariable* control = gSavedPerAccountSettings.getControl(SETTING)) - { - mSettingChanged = control->getSignal()->connect([this](LLControlVariable*, const LLSD&, const LLSD&) { - refresh(); + mNotecardChanged = mNotecard->onChanged([this](bool moved) { + refresh(); + if (moved) + { // Where it comes from changed, whatever the text. mChanged(); - }); - } + } + }); refresh(); } @@ -63,35 +60,19 @@ std::string ALScriptStudioVimrc::filePath() return gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "vimrc"); } -// static -std::string ALScriptStudioVimrc::cachePath() -{ - return gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, "script_studio_vimrc.xml"); -} - LLUUID ALScriptStudioVimrc::notecard() const { - const LLControlVariable* control = gSavedPerAccountSettings.getControl(SETTING); - return control ? LLUUID(control->getValue().asString()) : LLUUID::null; + return mNotecard->item(); } std::string ALScriptStudioVimrc::notecardName() const { - const LLUUID item = notecard(); - if (item.isNull()) - { - return std::string(); - } - if (const LLViewerInventoryItem* held = gInventory.getItem(item)) - { - return held->getName(); - } - return mCacheItem == item ? mCacheName : std::string(); + return mNotecard->name(); } void ALScriptStudioVimrc::useNotecard(const LLUUID& item) { - gSavedPerAccountSettings.setString(SETTING, item.isNull() ? std::string() : item.asString()); + mNotecard->use(item); } void ALScriptStudioVimrc::check(bool now) @@ -101,8 +82,7 @@ void ALScriptStudioVimrc::check(bool now) return; } mSinceCheck.reset(); - const LLUUID item = notecard(); - if (item.isNull()) + if (notecard().isNull()) { // Watched, and read as it changes; read now where asked, or where // it has yet to be watched. @@ -112,35 +92,19 @@ void ALScriptStudioVimrc::check(bool now) } return; } - const LLViewerInventoryItem* held = gInventory.getItem(item); - if (held && (mCacheItem != item || held->getAssetUUID() != mCacheAsset) && held->getAssetUUID() != mFetching) - { - refresh(); - } + mNotecard->check(); } void ALScriptStudioVimrc::refresh() { - const LLUUID item = notecard(); - if (item.isNull()) + if (notecard().isNull()) { readFile(); return; } // Not the vimrc while the notecard is. mWatch.reset(); - loadCache(); - const bool kept = mCacheItem == item; - const LLViewerInventoryItem* held = gInventory.getItem(item); - // The copy while it is this notecard's: the notecard as it stands - // where the item's asset is the one kept, or the last known while - // inventory has not come as far as the item; standing in, where the - // asset is another, until that comes. - take(kept ? mCacheText : std::string(), std::string()); - if (held && (!kept || held->getAssetUUID() != mCacheAsset)) - { - fetch(item); - } + take(mNotecard->text(), mNotecard->error()); } void ALScriptStudioVimrc::readFile() @@ -171,74 +135,6 @@ void ALScriptStudioVimrc::readFile() take(text, std::string()); } -void ALScriptStudioVimrc::fetch(const LLUUID& item) -{ - const LLViewerInventoryItem* held = gInventory.getItem(item); - if (!held || held->getAssetUUID() == mFetching) - { - return; - } - mFetching = held->getAssetUUID(); - ALScriptWorkspace::getInstance()->load(ALScriptRef(LLUUID::null, item), [item](const ALScriptLoaded& loaded) { - if (!ALScriptStudioVimrc::instanceExists()) - { - return; - } - ALScriptStudioVimrc& self = ALScriptStudioVimrc::instance(); - self.mFetching.setNull(); - if (self.notecard() != item) - { - // Another since. - return; - } - if (!loaded.error.empty() || !loaded.notecard) - { - // What was there stays; why it is not the notecard is said. - self.take(self.mText, loaded.error.empty() ? LLTrans::getString("VimrcNotNotecard") : loaded.error); - return; - } - self.mCacheItem = item; - self.mCacheAsset = loaded.assetId; - self.mCacheName = loaded.name; - self.mCacheText = loaded.text; - self.saveCache(); - self.take(loaded.text, std::string()); - }); -} - -void ALScriptStudioVimrc::loadCache() -{ - if (mCacheLoaded) - { - return; - } - mCacheLoaded = true; - llifstream in(cachePath()); - LLSD kept; - if (!in.is_open() || LLSDSerialize::fromXML(kept, in) <= 0 || !kept.isMap()) - { - return; - } - mCacheItem = kept["item"].asUUID(); - mCacheAsset = kept["asset"].asUUID(); - mCacheName = kept["name"].asString(); - mCacheText = kept["text"].asString(); -} - -void ALScriptStudioVimrc::saveCache() -{ - LLSD kept; - kept["item"] = mCacheItem; - kept["asset"] = mCacheAsset; - kept["name"] = mCacheName; - kept["text"] = mCacheText; - llofstream out(cachePath()); - if (out.is_open()) - { - LLSDSerialize::toPrettyXML(kept, out); - } -} - void ALScriptStudioVimrc::take(const std::string& text, const std::string& error) { if (text == mText && error == mError) diff --git a/indra/newview/alscriptstudiovimrc.h b/indra/newview/alscriptstudiovimrc.h index be0c547205..2a8db303e9 100644 --- a/indra/newview/alscriptstudiovimrc.h +++ b/indra/newview/alscriptstudiovimrc.h @@ -33,21 +33,14 @@ #include #include +class ALFollowedNotecard; class ALWatchedFile; // The vimrc Script Studio's vim reads: the file vimrc in the viewer's // settings folder, or a notecard dropped on the box for it in the -// studio's preferences, which is the vimrc while it is set -- kept in the -// account's settings, since the notecard is the account's. -// -// A notecard's text is kept on disk with the asset it was read from. -// An asset never changes: an edit to the notecard makes a new one and -// gives the item its id, so while the item's asset is the one kept the -// copy is the notecard as it stands, and it is read from disk rather -// than fetched again -- at once, before inventory has so much as loaded, -// so that the mappings are there from the first key. An item that has -// moved on to another asset is fetched, the copy standing in until the -// new text comes. +// studio's preferences, which is the vimrc while it is set -- followed by +// ALFollowedNotecard, which keeps its text on disk with the asset it was +// read from, so that the mappings are there from the first key. class ALScriptStudioVimrc final : public LLSingleton { LLSINGLETON(ALScriptStudioVimrc); @@ -87,12 +80,7 @@ class ALScriptStudioVimrc final : public LLSingleton // What the vimrc is now, from wherever the setting says. void refresh(); void readFile(); - void fetch(const LLUUID& item); - // The notecard's copy on disk, read once and written as it changes. - static std::string cachePath(); - void loadCache(); - void saveCache(); - void take(const std::string& text, const std::string& error); + void take(const std::string& text, const std::string& error); std::string mText; std::string mError; @@ -100,15 +88,7 @@ class ALScriptStudioVimrc final : public LLSingleton // changes (ALWatchedFile, off the main thread). std::unique_ptr mWatch; LLTimer mSinceCheck; - // The notecard's copy kept on disk: whose, from which asset, its name - // and its text. - bool mCacheLoaded = false; - LLUUID mCacheItem; - LLUUID mCacheAsset; - std::string mCacheName; - std::string mCacheText; - // The asset a fetch is on its way for, so that another is not asked. - LLUUID mFetching; - boost::signals2::scoped_connection mSettingChanged; + std::unique_ptr mNotecard; + boost::signals2::scoped_connection mNotecardChanged; changed_signal_t mChanged; }; diff --git a/indra/newview/app_settings/settings_per_account_alchemy.xml b/indra/newview/app_settings/settings_per_account_alchemy.xml index abbff9878c..3dfc424e78 100644 --- a/indra/newview/app_settings/settings_per_account_alchemy.xml +++ b/indra/newview/app_settings/settings_per_account_alchemy.xml @@ -121,6 +121,17 @@ Value 0 + ALScriptStudioSnippetsNotecard + + Comment + The snippet notecard in inventory that Script Studio follows, by its item; empty for none. Its snippets are offered beside the viewer's and your own as it stands; its text is kept on disk with the asset it was read from, and fetched again only when the notecard has a new one. + Persist + 1 + Type + String + Value + + ALScriptStudioVimrcNotecard Comment diff --git a/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml b/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml index e44327f2ba..c52f94ab6a 100644 --- a/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml +++ b/indra/newview/skins/default/xui/en/floater_script_studio_prefs.xml @@ -42,6 +42,10 @@ Script snippets the notecard could not be made Only a notecard in your inventory can bring snippets in + Only a notecard in your inventory can be followed for snippets + From [NAME], the notecard you follow. Copy it to make a version of your own, or change it in the notecard. + (a notecard not in inventory yet) + Drop a snippets notecard here to follow it: its snippets are offered beside yours, as the notecard stands whoever changes it. Cached until it changes A declaration that hides one in an outer scope An assignment used as a condition Changing to the current state @@ -1258,7 +1262,7 @@ end left="12" multi_select="false" name="snippet_list" - tool_tip="Your snippets, then the viewer's built-in ones (copy one to change it). Drop a snippets notecard here to add its snippets to yours." + tool_tip="Your snippets, then the followed notecard's, then the viewer's built-in ones (copy one to change it). Drop a snippets notecard here to add its snippets to yours." top_pad="8" width="280"> @@ -1305,6 +1309,38 @@ end top_pad="4" width="140" /> + + Follow notecard: + + +