From edbcf723a72df06c081408d29adaf75f6c09d4dc Mon Sep 17 00:00:00 2001 From: "Michael Z. Kadaner" Date: Wed, 25 Dec 2024 23:11:18 -0500 Subject: [PATCH] gh-802: Macro API to report VMenu alignment state. Part 1: Backend implementation. Implemented `vmenu_horizontal_tracker` which knows whether menu items are currently aligned. Surfacing the state maintained by `vmenu_horizontal_tracker` is coming soon. --- far/changelog | 5 ++ far/vbuild.m4 | 2 +- far/vmenu.cpp | 182 +++++++++++++++++++++++++++++++++++++------------- far/vmenu.hpp | 69 +++++++++++++++++-- 4 files changed, 206 insertions(+), 52 deletions(-) diff --git a/far/changelog b/far/changelog index de6e99a788..d06e398b0a 100644 --- a/far/changelog +++ b/far/changelog @@ -1,3 +1,8 @@ +-------------------------------------------------------------------------------- +MZK 2025-01-04 21:34:32-05:00 - build 6411 + +1. gh-802: Macro API to report VMenu alignment state; part 1. Backend implementation. + -------------------------------------------------------------------------------- drkns 2025-01-04 21:34:31+00:00 - build 6410 diff --git a/far/vbuild.m4 b/far/vbuild.m4 index e8f41c6b56..a38526318d 100644 --- a/far/vbuild.m4 +++ b/far/vbuild.m4 @@ -1 +1 @@ -6410 +6411 diff --git a/far/vmenu.cpp b/far/vmenu.cpp index a10e3aa2ce..abed2a0a2f 100644 --- a/far/vmenu.cpp +++ b/far/vmenu.cpp @@ -181,9 +181,28 @@ struct menu_layout } }; +enum class item_hscroll_policy +{ + unbound, // The item can move freely beyond window edges. + cling_to_edge, // The item can move beyond window edges, but at least one character is always visible. + bound, // The item can move only within the window boundaries. + bound_stick_to_left // Like bound, but if the item shorter than TextAreaWidth, it is always attached to the left window edge. +}; + namespace { - MenuItemEx far_list_to_menu_item(const FarListItem& FItem) + const FarListItem string_to_far_list_item(const wchar_t* NewStrItem) + { + if (!NewStrItem) + return { .Flags{ LIF_SEPARATOR } }; + + if (NewStrItem[0] == 0x1) + return { .Flags{ LIF_SEPARATOR }, .Text{ NewStrItem + 1 } }; + + return { .Text{ NewStrItem } }; + } + + MenuItemEx far_list_item_to_menu_item(const FarListItem& FItem) { MenuItemEx Result; Result.Flags = FItem.Flags; @@ -291,14 +310,6 @@ namespace return static_cast(ShowAmpersand ? visual_string_length(ItemName) : HiStrlen(ItemName)); } - enum class item_hscroll_policy - { - unbound, // The item can move freely beyond window edges. - cling_to_edge, // The item can move beyond window edges, but at least one character is always visible. - bound, // The item can move only within the window boundaries. - bound_stick_to_left // Like bound, but if the item shorter than TextAreaWidth, it is always attached to the left window edge. - }; - std::pair item_hpos_limits(const int ItemLength, const int TextAreaWidth, const item_hscroll_policy Policy) noexcept { using enum item_hscroll_policy; @@ -705,7 +716,7 @@ int VMenu::InsertItem(const FarListInsert *NewItem) { if (NewItem) { - if (AddItem(far_list_to_menu_item(NewItem->Item), NewItem->Index) >= 0) + if (AddItem(far_list_item_to_menu_item(NewItem->Item), NewItem->Index) >= 0) return static_cast(Items.size()); } @@ -718,7 +729,7 @@ int VMenu::AddItem(const FarList* List) { for (const auto& Item: std::span(List->Items, List->ItemsNumber)) { - AddItem(far_list_to_menu_item(Item)); + AddItem(far_list_item_to_menu_item(Item)); } } @@ -727,22 +738,7 @@ int VMenu::AddItem(const FarList* List) int VMenu::AddItem(const wchar_t *NewStrItem) { - FarListItem FarListItem0{}; - - if (!NewStrItem || NewStrItem[0] == 0x1) - { - FarListItem0.Flags=LIF_SEPARATOR; - if (NewStrItem) - FarListItem0.Text = NewStrItem + 1; - } - else - { - FarListItem0.Text=NewStrItem; - } - - const FarList List{ sizeof(List), 1, &FarListItem0 }; - - return AddItem(&List) - 1; //-1 потому что AddItem(FarList) возвращает количество элементов + return AddItem(far_list_item_to_menu_item(string_to_far_list_item(NewStrItem))); } int VMenu::AddItem(MenuItemEx&& NewItem,int PosAdd) @@ -761,7 +757,7 @@ int VMenu::AddItem(MenuItemEx&& NewItem,int PosAdd) const auto ItemLength{ get_item_visual_length(CheckFlags(VMENU_SHOWAMPERSAND), NewMenuItem.Name) }; UpdateMaxLength(ItemLength); - UpdateAllItemsBoundaries(NewMenuItem.HorizontalPosition, ItemLength); + m_HorizontalTracker.add_item(NewMenuItem.HorizontalPosition, ItemLength, NewMenuItem.SafeGetFirstAnnotation()); const auto NewFlags = NewMenuItem.Flags; NewMenuItem.Flags = 0; @@ -778,12 +774,14 @@ bool VMenu::UpdateItem(const FarListUpdate *NewItem) return false; auto& Item = Items[NewItem->Index]; + m_HorizontalTracker.remove_item(Item.HorizontalPosition, get_item_visual_length(CheckFlags(VMENU_SHOWAMPERSAND), Item.Name), Item.SafeGetFirstAnnotation()); // Освободим память... от ранее занятого ;-) if (NewItem->Item.Flags&LIF_DELETEUSERDATA) { Item.ComplexUserData = {}; } + Item.Annotations.clear(); Item.Name = NullToEmpty(NewItem->Item.Text); UpdateItemFlags(NewItem->Index, NewItem->Item.Flags); @@ -791,7 +789,7 @@ bool VMenu::UpdateItem(const FarListUpdate *NewItem) const auto ItemLength{ get_item_visual_length(CheckFlags(VMENU_SHOWAMPERSAND), Item.Name) }; UpdateMaxLength(ItemLength); - UpdateAllItemsBoundaries(Item.HorizontalPosition, ItemLength); + m_HorizontalTracker.add_item(Item.HorizontalPosition, ItemLength, Item.SafeGetFirstAnnotation()); SetMenuFlags(VMENU_UPDATEREQUIRED | (bFilterEnabled ? VMENU_REFILTERREQUIRED : VMENU_NONE)); @@ -816,19 +814,23 @@ int VMenu::DeleteItem(int ID, int Count) return static_cast(Items.size()); } - for (const auto I: std::views::iota(0, Count)) + for (const auto& I : std::span{ Items.cbegin() + ID, static_cast(Count) }) { - if (Items[ID+I].Flags & MIF_SUBMENU) + if (I.Flags & MIF_SUBMENU) --ItemSubMenusCount; - if (!item_is_visible(Items[ID+I])) + if (!item_is_visible(I)) --ItemHiddenCount; + + m_HorizontalTracker.remove_item(I.HorizontalPosition, get_item_visual_length(CheckFlags(VMENU_SHOWAMPERSAND), I.Name), I.SafeGetFirstAnnotation()); } // а вот теперь перемещения - const auto FirstIter = Items.begin() + ID, LastIter = FirstIter + Count; if (Items.size() > 1) + { + const auto FirstIter = Items.begin() + ID, LastIter = FirstIter + Count; Items.erase(FirstIter, LastIter); + } // коррекция текущей позиции if (SelectPos >= ID && SelectPos < ID+Count) @@ -862,7 +864,7 @@ void VMenu::clear() TopPos=0; m_MaxItemLength = 0; UpdateMaxLengthFromTitles(); - ResetAllItemsBoundaries(); + m_HorizontalTracker.reset(); SetMenuFlags(VMENU_UPDATEREQUIRED); } @@ -2121,11 +2123,11 @@ bool VMenu::SetItemHPos(MenuItemEx& Item, const auto& GetNewHPos) return GetNewHPos(Item.HorizontalPosition, ItemLength); }(); - UpdateAllItemsBoundaries(NewHPos, ItemLength); + m_HorizontalTracker.update_item_hpos(Item.HorizontalPosition , NewHPos, ItemLength, Item.SafeGetFirstAnnotation()); if (Item.HorizontalPosition == NewHPos) return false; - Item.HorizontalPosition = NewHPos; + return true; } @@ -2167,7 +2169,6 @@ bool VMenu::ShiftCurItemHPos(const int Shift) bool VMenu::SetAllItemsHPos(const auto& GetNewHPos) { - ResetAllItemsBoundaries(); bool NeedRedraw{}; for (auto& Item : Items) @@ -2184,6 +2185,14 @@ bool VMenu::SetAllItemsSmartHPos(const int NewHPos) const auto Policy{ CheckFlags(VMENU_ENABLEALIGNANNOTATIONS) ? item_hscroll_policy::unbound : item_hscroll_policy::bound_stick_to_left }; + const auto Cookie{ [&]() + { + if (NewHPos >= 0) + return m_HorizontalTracker.start_bulk_update(vmenu_horizontal_tracker::alignment::Left, NewHPos, TextAreaWidth, Policy); + + return m_HorizontalTracker.start_bulk_update( + vmenu_horizontal_tracker::alignment::Right, TextAreaWidth + NewHPos + 1, TextAreaWidth, Policy); + }() }; return SetAllItemsHPos( [&](const int ItemLength) { return get_item_smart_hpos(NewHPos, ItemLength, TextAreaWidth, Policy); }); } @@ -2193,11 +2202,14 @@ bool VMenu::ShiftAllItemsHPos(const int Shift) const auto TextAreaWidth{ CalculateTextAreaWidth() }; if (TextAreaWidth <= 0) return false; - const auto AdjustedShift{ adjust_hpos_shift(Shift, m_AllItemsBoundaries.first, m_AllItemsBoundaries.second, TextAreaWidth) }; + const auto AdjustedShift{ adjust_hpos_shift(Shift, m_HorizontalTracker.m_LBoundary, m_HorizontalTracker.m_RBoundary, TextAreaWidth) }; if (!AdjustedShift) return false; const auto Policy{ CheckFlags(VMENU_ENABLEALIGNANNOTATIONS) ? item_hscroll_policy::unbound : item_hscroll_policy::bound_stick_to_left }; + const auto Cookie{ + m_HorizontalTracker.start_bulk_update( + m_HorizontalTracker.m_Alignment, m_HorizontalTracker.m_AlignedAtColumn + AdjustedShift, TextAreaWidth, Policy) }; return SetAllItemsHPos( [&](const int ItemHPos, const int ItemLength) { return get_item_absolute_hpos(ItemHPos + AdjustedShift, ItemLength, TextAreaWidth, Policy); }); } @@ -2210,8 +2222,10 @@ bool VMenu::AlignAnnotations() if (TextAreaWidth <= 0) return false; const auto AlignPos{ (TextAreaWidth + 2) / 4 }; + const auto Cookie{ + m_HorizontalTracker.start_bulk_update(vmenu_horizontal_tracker::alignment::Annotation, AlignPos, TextAreaWidth, item_hscroll_policy::unbound) }; return SetAllItemsHPos( - [&](const MenuItemEx& Item) { return Item.Annotations.empty() ? 0 : AlignPos - Item.Annotations.front().first; }); + [&](const MenuItemEx& Item) { return AlignPos - Item.SafeGetFirstAnnotation(); }); } void VMenu::Show() @@ -2487,9 +2501,25 @@ void VMenu::DrawTitles() const if constexpr ((false)) { + const auto AlignmentMark{ [](vmenu_horizontal_tracker::alignment Alignment) + { + switch (Alignment) + { + case vmenu_horizontal_tracker::alignment::Left: return L'<'; + case vmenu_horizontal_tracker::alignment::Right: return L'>'; + case vmenu_horizontal_tracker::alignment::Annotation: return L'^'; + default: std::unreachable(); + } + } }; + set_color(Colors, color_indices::Title); - const auto AllItemsBoundariesLabel{ std::format(L" [{}, {}] ", m_AllItemsBoundaries.first, m_AllItemsBoundaries.second) }; + const auto AllItemsBoundariesLabel{ std::format(L" [{}:{} {} {} {}] ", + m_HorizontalTracker.m_LBoundary, + m_HorizontalTracker.m_RBoundary, + AlignmentMark(m_HorizontalTracker.m_Alignment), + m_HorizontalTracker.m_AlignedAtColumn, + m_HorizontalTracker.m_StrayItems) }; GotoXY(m_Where.left + 2, m_Where.bottom); Text(AllItemsBoundariesLabel); @@ -2825,18 +2855,76 @@ void VMenu::UpdateMaxLength(int const ItemLength) m_MaxItemLength = std::max(m_MaxItemLength, ItemLength); } -void VMenu::ResetAllItemsBoundaries() +bool vmenu_horizontal_tracker::is_item_aligned(int const ItemHPos, int const ItemLength, int const ItemAnnotationPos) { - m_AllItemsBoundaries = { std::numeric_limits::max(), std::numeric_limits::min() }; + const auto AnchorOffset{ [&]() + { + switch (m_Alignment) + { + case alignment::Left: return 0; + case alignment::Right: return ItemLength; + case alignment::Annotation: return ItemAnnotationPos; + default: std::unreachable(); + } + }() }; + + return ItemHPos + AnchorOffset == m_AlignedAtColumn; } -void VMenu::UpdateAllItemsBoundaries(int const ItemHPos, int const ItemLength) +void vmenu_horizontal_tracker::update_boundaries(int const ItemHPos, int const ItemLength) { - m_AllItemsBoundaries = + m_LBoundary = std::min(m_LBoundary, ItemHPos); + m_RBoundary = std::max(m_RBoundary, ItemHPos + ItemLength); +} + +void vmenu_horizontal_tracker::add_item(int const ItemHPos, int const ItemLength, int const ItemAnnotationPos) +{ + update_boundaries(ItemHPos, ItemLength); + + if (!is_item_aligned(ItemHPos, ItemLength, ItemAnnotationPos)) + m_StrayItems++; +} + +void vmenu_horizontal_tracker::remove_item(int const ItemHPos, int const ItemLength, int const ItemAnnotationPos) +{ + if (!is_item_aligned(ItemHPos, ItemLength, ItemAnnotationPos)) + m_StrayItems--; +} + +vmenu_horizontal_tracker::cookie vmenu_horizontal_tracker::start_bulk_update( + alignment const Alignment, int const AlignedAtColumn, int const TextAreaWidth, item_hscroll_policy const Policy) +{ + reset(); + + m_IsBulkUpdate = true; + m_Alignment = Alignment; + + if (m_Alignment == alignment::Annotation || Policy == item_hscroll_policy::unbound) { - std::min(m_AllItemsBoundaries.first, ItemHPos), - std::max(m_AllItemsBoundaries.second, ItemHPos + ItemLength) - }; + m_AlignedAtColumn = AlignedAtColumn; + } + else if (m_Alignment == alignment::Left) + { + m_AlignedAtColumn = std::min(AlignedAtColumn, Policy == item_hscroll_policy::cling_to_edge ? TextAreaWidth - 1 : 0); + } + else if (m_Alignment == alignment::Right) + { + m_AlignedAtColumn = std::max(AlignedAtColumn, Policy == item_hscroll_policy::cling_to_edge ? 1 : TextAreaWidth); + } + else + { + std::unreachable(); + } + + return cookie{ this }; +} + +void vmenu_horizontal_tracker::update_item_hpos(int const OldItemHPos, int const NewItemHPos, int const ItemLength, int const ItemAnnotationPos) +{ + if (!m_IsBulkUpdate) + remove_item(OldItemHPos, ItemLength, ItemAnnotationPos); + + add_item(NewItemHPos, ItemLength, ItemAnnotationPos); } void VMenu::SetMaxHeight(int NewMaxHeight) diff --git a/far/vmenu.hpp b/far/vmenu.hpp index 2bbcda5c59..51e3363d96 100644 --- a/far/vmenu.hpp +++ b/far/vmenu.hpp @@ -129,7 +129,6 @@ struct menu_item LISTITEMFLAGS SetSelect(bool Value) { if (Value) Flags|=LIF_SELECTED; else Flags&=~LIF_SELECTED; return Flags;} LISTITEMFLAGS SetDisable(bool Value) { if (Value) Flags|=LIF_DISABLE; else Flags&=~LIF_DISABLE; return Flags;} LISTITEMFLAGS SetGrayed(bool Value) { if (Value) Flags|=LIF_GRAYED; else Flags&=~LIF_GRAYED; return Flags;} - }; struct MenuItemEx: menu_item @@ -147,9 +146,12 @@ struct MenuItemEx: menu_item wchar_t AutoHotkey{}; size_t AutoHotkeyPos{}; std::list> Annotations; + + int SafeGetFirstAnnotation() const noexcept { return Annotations.empty() ? 0 : Annotations.front().first; } }; struct menu_layout; +enum class item_hscroll_policy; struct SortItemParam { @@ -157,6 +159,67 @@ struct SortItemParam int Offset; }; +// Keeps track of the horizontal state of all items. +// The tracking is best effort. See comments below. +// Everything is relative to menu_layout::TextArea::first (Left edge). +class vmenu_horizontal_tracker +{ + struct cookie + { + NONCOPYABLE(cookie); + explicit cookie(vmenu_horizontal_tracker* Tracker) noexcept: m_Tracker{ Tracker } {} + ~cookie() noexcept { m_Tracker->m_IsBulkUpdate = false; } + vmenu_horizontal_tracker* m_Tracker{}; + }; + + void update_boundaries(int ItemHPos, int ItemLength); + bool is_item_aligned(int ItemHPos, int ItemLength, int ItemAnnotationPos); + +public: + enum class alignment { Left, Right, Annotation }; + + void reset() { *this = {}; } + void add_item(int ItemHPos, int ItemLength, int ItemAnnotationPos); + void remove_item(int ItemHPos, int ItemLength, int ItemAnnotationPos); + + [[nodiscard]] cookie start_bulk_update(alignment Alignment, int AlignedAtColumn, int TextAreaWidth, item_hscroll_policy Policy); + + // Use only to update item's HorizontalPosition. + // If either ItemLength or ItemAnnotationPos may have changed, use remove_item and add_item. + // If called during bulk update operation, it does not decrement m_StrayItems. + void update_item_hpos(int OldItemHPos, int NewItemHPos, int ItemLength, int ItemAnnotationPos); + + // All items are within the boundaries, always. + // Boundaries track items as they are shifted horizontally. + // The tracking is best effort. Boundaries are: + // - Not tight, i.e., there may be no item actually touching a boundary. + // - Expanded but never contracted. + // - Fully recalculated during align operations (left, right, or at annotations). + int m_LBoundary{ std::numeric_limits::max() }; + int m_RBoundary{ std::numeric_limits::min() }; + + // All items have their corresponding anchor position (left, right, + // or annotation) at the m_AlignedAtColumn. See also m_StrayItems. + alignment m_Alignment{ alignment::Left }; + + // The column at which all items' anchors are aligned. + // Depending on item_hscroll_policy, may be an arbitrary column, even outside of menu_layout::TextArea. + int m_AlignedAtColumn{}; + + // The number of items which were shifted horizontally out of general alignment. + // Incremented each time an item is shifted out of alignment. + // Decremented each time an item clicks back into place. May become zero, + // which is the whole point of the vmenu_horizontal_tracker contraption. + // It is reset to zero at the beginning of bulk operations. + // The tracking is best effort. For example, if m_Alignment is Right + // and each item was shifted individually so that all of them became aligned + // to the left edge of the text area, m_Alignment does not become Left. + int m_StrayItems{}; + +private: + bool m_IsBulkUpdate{}; +}; + class VMenu final: public Modal { struct private_tag { explicit private_tag() = default; }; @@ -302,8 +365,6 @@ class VMenu final: public Modal void UpdateMaxLengthFromTitles(); void UpdateMaxLength(int ItemLength); - void ResetAllItemsBoundaries(); - void UpdateAllItemsBoundaries(int ItemHPos, int ItemLength); bool ShouldSendKeyToFilter(unsigned Key) const; //корректировка текущей позиции и флагов SELECTED void UpdateSelectPos(); @@ -320,7 +381,7 @@ class VMenu final: public Modal int MaxHeight; bool WasAutoHeight{}; int m_MaxItemLength{}; - std::pair m_AllItemsBoundaries{}; + vmenu_horizontal_tracker m_HorizontalTracker; window_ptr CurrentWindow; bool PrevCursorVisible{}; size_t PrevCursorSize{};