2626#include < format>
2727#include < memory>
2828#include < ranges>
29+ #include < span>
2930#include < string>
31+ #include < string_view>
32+ #include < tuple>
3033#include < unordered_map>
3134#include < utility>
3235#include < vector>
@@ -739,6 +742,115 @@ namespace {
739742 return out;
740743 }
741744
745+ std::vector<std::string_view> deviceLabelTokens (std::string_view label) {
746+ std::vector<std::string_view> tokens;
747+ std::size_t pos = 0 ;
748+ while (pos < label.size ()) {
749+ while (pos < label.size () && label[pos] == ' ' ) {
750+ ++pos;
751+ }
752+ const std::size_t start = pos;
753+ while (pos < label.size () && label[pos] != ' ' ) {
754+ ++pos;
755+ }
756+ if (pos > start) {
757+ tokens.push_back (label.substr (start, pos - start));
758+ }
759+ }
760+ return tokens;
761+ }
762+
763+ std::size_t commonTokenCount (std::span<const std::string_view> a, std::span<const std::string_view> b) {
764+ const std::size_t limit = std::min (a.size (), b.size ());
765+ std::size_t count = 0 ;
766+ while (count < limit && a[count] == b[count]) {
767+ ++count;
768+ }
769+ return count;
770+ }
771+
772+ std::string joinTokens (std::span<const std::string_view> tokens) {
773+ std::string out;
774+ for (const std::string_view token : tokens) {
775+ if (!out.empty ()) {
776+ out.push_back (' ' );
777+ }
778+ out.append (token);
779+ }
780+ return out;
781+ }
782+
783+ struct DeviceMenuItem {
784+ std::uint32_t id = 0 ;
785+ std::string label;
786+ bool selected = false ;
787+ };
788+
789+ // PipeWire sink/source descriptions are "card + profile/port" concatenations, so a multi-port card
790+ // yields several near-identical labels. Fold labels sharing a substantial token prefix under one
791+ // non-interactive header so each entry carries only its distinguishing tail.
792+ std::vector<ContextMenuControlEntry> buildDeviceMenuEntries (std::vector<DeviceMenuItem> items) {
793+ constexpr std::size_t kMinSharedTokens = 2 ;
794+
795+ std::ranges::sort (items, [](const DeviceMenuItem& a, const DeviceMenuItem& b) {
796+ return std::tie (a.label , a.id ) < std::tie (b.label , b.id );
797+ });
798+
799+ std::vector<std::vector<std::string_view>> tokens;
800+ tokens.reserve (items.size ());
801+ for (const DeviceMenuItem& item : items) {
802+ tokens.push_back (deviceLabelTokens (item.label ));
803+ }
804+
805+ auto deviceEntry = [](const DeviceMenuItem& item, std::string label) {
806+ return ContextMenuControlEntry{
807+ .id = static_cast <std::int32_t >(item.id ),
808+ .label = std::move (label),
809+ .radio = true ,
810+ .toggleState = item.selected ? 1 : 0 ,
811+ .ellipsize = TextEllipsize::Middle,
812+ };
813+ };
814+
815+ std::vector<ContextMenuControlEntry> entries;
816+ entries.reserve (items.size ());
817+ std::size_t i = 0 ;
818+ while (i < items.size ()) {
819+ // Grow the run while the shared token prefix stays substantial.
820+ std::size_t shared = tokens[i].size ();
821+ std::size_t j = i + 1 ;
822+ while (j < items.size ()) {
823+ const std::size_t common = commonTokenCount (std::span (tokens[i]).first (shared), tokens[j]);
824+ if (common < kMinSharedTokens ) {
825+ break ;
826+ }
827+ shared = common;
828+ ++j;
829+ }
830+ // A member whose whole label is the shared prefix would fold to an empty entry; keep such
831+ // runs unfolded (the first item is emitted alone and grouping restarts at the next one).
832+ bool foldable = j - i >= 2 ;
833+ for (std::size_t k = i; foldable && k < j; ++k) {
834+ foldable = tokens[k].size () > shared;
835+ }
836+ if (foldable) {
837+ entries.push_back ({
838+ .label = joinTokens (std::span (tokens[i]).first (shared)),
839+ .header = true ,
840+ .ellipsize = TextEllipsize::Middle,
841+ });
842+ for (std::size_t k = i; k < j; ++k) {
843+ entries.push_back (deviceEntry (items[k], joinTokens (std::span (tokens[k]).subspan (shared))));
844+ }
845+ i = j;
846+ } else {
847+ entries.push_back (deviceEntry (items[i], items[i].label ));
848+ ++i;
849+ }
850+ }
851+ return entries;
852+ }
853+
742854 class AudioDeviceRow : public Flex {
743855 public:
744856 explicit AudioDeviceRow (float scale, std::function<void ()> onSelect) : m_onSelect(std::move(onSelect)) {
@@ -1323,19 +1435,16 @@ void AudioTab::openDeviceMenu(DeviceVolumeCardState& card, const DeviceMenuModel
13231435 const AudioState& state = m_audio->state ();
13241436
13251437 const std::uint32_t defaultDeviceId = menu.defaultDeviceId (state);
1326- auto entries = availableDevices (menu.devices (state), defaultDeviceId)
1438+ auto items = availableDevices (menu.devices (state), defaultDeviceId)
13271439 | std::views::transform ([&](const AudioNode& node) {
1328- const std::string_view selectedPrefix = node.id == defaultDeviceId ? " • " : " " ;
1329- return ContextMenuControlEntry{
1330- .id = static_cast <std::int32_t >(node.id ),
1331- .label = std::format (" {}{}" , selectedPrefix, audioDeviceLabel (node)),
1332- .enabled = true ,
1333- .separator = false ,
1334- .hasSubmenu = false ,
1335- .ellipsize = TextEllipsize::Middle,
1336- };
1337- })
1440+ return DeviceMenuItem{
1441+ .id = node.id ,
1442+ .label = audioDeviceLabel (node),
1443+ .selected = node.id == defaultDeviceId,
1444+ };
1445+ })
13381446 | std::ranges::to<std::vector>();
1447+ auto entries = buildDeviceMenuEntries (std::move (items));
13391448
13401449 if (card.menuAnchor == nullptr ) {
13411450 return ;
@@ -1350,8 +1459,11 @@ void AudioTab::openDeviceMenu(DeviceVolumeCardState& card, const DeviceMenuModel
13501459 float anchorAbsY = 0 .0f ;
13511460 Node::absolutePosition (card.menuAnchor , anchorAbsX, anchorAbsY);
13521461
1462+ // Size the popup to its entries: at least the old card-bound width, but free to grow past the
1463+ // half-width card so long device names stay readable.
13531464 const float scale = contentScale ();
1354- const float menuWidth = std::min (280 .0f * scale, card.menuAnchor ->width ());
1465+ const float minMenuWidth = std::min (280 .0f * scale, card.menuAnchor ->width ());
1466+ const float maxMenuWidth = 420 .0f * scale;
13551467
13561468 if (m_config != nullptr ) {
13571469 m_deviceMenuPopup->setShadowConfig (m_config->config ().shell .shadow );
@@ -1376,7 +1488,8 @@ void AudioTab::openDeviceMenu(DeviceVolumeCardState& card, const DeviceMenuModel
13761488 m_deviceMenuPopup->open (
13771489 ContextMenuPopupRequest{
13781490 .entries = std::move (entries),
1379- .menuWidth = menuWidth,
1491+ .minMenuWidth = minMenuWidth,
1492+ .maxMenuWidth = maxMenuWidth,
13801493 .maxVisible = 10 ,
13811494 .anchor =
13821495 PopupAnchorRect{
0 commit comments