Modules/LibraryViewer/private/GUI.cpp
Шурупов Илья Викторович 8d5826601c Widgets: unify optional index selection APIs
Use SelectionModel-backed optional indices across dropdowns, radio groups, segmented controls, tabs, and swatch grids. Migrate consumers while retaining existing notification timing and typed segment values.

Consolidate overlapping selection scenarios and exercise valid, invalid, and empty indices across the shared interface.
2026-11-03 15:23:52 +03:00

300 lines
9.7 KiB
C++

#include "GUI.hpp"
#include "BoxLayout.hpp"
#include "Canvas.hpp"
#include "StackLayout.hpp"
#include "Event.hpp"
#include "Glyph.hpp"
#include "Theme.hpp"
#include "ValueFormat.hpp"
#include <algorithm>
#include <cctype>
using namespace tp;
using namespace tp::ui;
namespace {
constexpr halnf kSearchWidth = 280;
constexpr halnf kMillisecondsPerSecond = 1000;
constexpr halnf kInfoCoverMaxSize = 240;
constexpr halnf kInfoTitleSize = 18;
constexpr halnf kInfoPanelWidth = 300;
std::string lowercase(std::string text) {
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return (char) std::tolower(c); });
return text;
}
bool containsIgnoringCase(const std::string& haystack, const std::string& needle) {
return lowercase(haystack).find(needle) != std::string::npos;
}
}
TrackInfoView::TrackInfoView() {
setDebugName("track info");
setSizing(AxisSizing::fill(), AxisSizing::fill());
emplaceLayout<StackLayout>();
cover.setSizing(AxisSizing::fill().atMost(kInfoCoverMaxSize), AxisSizing::fixed(kInfoCoverMaxSize));
state.setTone(Label::Tone::Muted);
state.setFontSize(theme().metrics.smallFontSize);
title.setFontSize(kInfoTitleSize);
artist.setTone(Label::Tone::Muted);
for (Label* label : { &state, &title, &artist })
label->setSizing(AxisSizing::fill(), AxisSizing::fit());
// Every row reads the shown track, so refreshing the list after showing a track fills them all.
const auto field = [this](std::function<std::string(const Track&)> read) {
return [this, read = std::move(read)]() { return mTrack ? read(*mTrack) : std::string(); };
};
details.addValue("Album", field([](const Track& track) { return track.mAlbum; }));
details.addValue("Genre", field([](const Track& track) { return track.mGenre; }));
details.addValue("Year", field([](const Track& track) { return track.mYear > 0 ? std::to_string(track.mYear) : std::string("-"); }));
details.addValue("Length", field([](const Track& track) { return format::duration((halnf) track.mTotalTime / kMillisecondsPerSecond); }));
details.addValue("Plays", field([](const Track& track) { return std::to_string(track.mPlayCount); }));
details.addValue("Loved", field([](const Track& track) { return std::string(track.mLoved ? "yes" : "no"); }));
details.addValue("File", field([](const Track& track) { return std::string(track.mExists ? "on disk" : "missing"); }));
details.addValue("Added", field([](const Track& track) { return track.mDateAdded; }));
mColumn.setSizing(AxisSizing::fill(), AxisSizing::fit());
mColumn.emplaceLayout<ColumnLayout>(6).setPadding(Insets::uniform(12));
mColumn.addChild(cover);
mColumn.addChild(state);
mColumn.addChild(title);
mColumn.addChild(artist);
mColumn.addChild(details);
mScroll.setContent(&mColumn);
mScroll.setSizing(AxisSizing::fill(), AxisSizing::fill());
addChild(mScroll);
showTrack(nullptr, false);
}
void TrackInfoView::showTrack(const Track* track, bool playing) {
mTrack = track;
details.setVisible(track != nullptr);
details.refresh();
if (!track) {
state.setText("");
title.setText("No track selected");
artist.setText("Select a track to see its details");
return;
}
state.setText(playing ? "NOW PLAYING" : "SELECTED");
title.setText(track->mName);
artist.setText(track->mArtist);
}
bool TrackFilter::accepts(const Track& track) const {
if (!text.empty()) {
const std::string needle = lowercase(text);
const bool matches = containsIgnoringCase(track.mName, needle) || containsIgnoringCase(track.mArtist, needle) ||
containsIgnoringCase(track.mAlbum, needle);
if (!matches) return false;
}
if (lovedOnly && !track.mLoved) return false;
if (existence == ExistenceFilter::Existing && !track.mExists) return false;
if (existence == ExistenceFilter::Missing && track.mExists) return false;
return true;
}
LibraryWidget::LibraryWidget(Library& library, PlaybackDevice& device) :
Panel(Surface::Window),
mLibrary(library),
mDevice(device) {
setDebugName("library");
setSizing(AxisSizing::fill(), AxisSizing::fill());
emplaceLayout<ColumnLayout>();
search.setPlaceholder("Search title, artist or album");
search.setSizing(AxisSizing::fixed(kSearchWidth), AxisSizing::fit());
search.editor().setOnEdited([this](const std::string& text) {
mFilter.text = text;
applyFilter();
});
lovedOnly.setOnChanged([this](bool loved) {
mFilter.lovedOnly = loved;
applyFilter();
});
files.selectIndex(0);
files.setOnSelectionChanged([this](std::optional<size_t> index) {
if (!index) return;
mFilter.existence = (ExistenceFilter) *index;
applyFilter();
});
count.setTone(Label::Tone::Muted);
mFilterBar.setSizing(AxisSizing::fill(), AxisSizing::fit());
RowLayout& filterRow = mFilterBar.emplaceLayout<RowLayout>(10);
filterRow.setPadding(Insets::symmetric(12, 8));
filterRow.setCrossAlign(CrossAlign::Center);
mFilterBar.addChild(search);
mFilterBar.addChild(lovedOnly);
mFilterBar.addChild(files);
mFilterBar.addChild(count);
mTracks.setDensity(ListRow::Density::Comfortable);
mTracks.setEmptyText("No tracks match the filter");
mTracks.setOnSelectionChanged([this]() {
if (mTracks.current()) select(*mTracks.current());
});
mTracks.setOnActivated([this](size_t index) {
if (index < mVisible.size()) play(*mVisible[index]);
});
mBrowser.setSizing(AxisSizing::fill(), AxisSizing::fill());
mBrowser.emplaceLayout<ColumnLayout>();
mBrowser.addChild(mFilterBar);
mBrowser.addChild(mTracks);
infoPanel.setContent(&info);
infoPanel.setDockedLength(kInfoPanelWidth);
dock.setSizing(AxisSizing::fill(), AxisSizing::fill());
dock.setCenter(&mBrowser);
dock.addPanel(infoPanel);
dock.openPanel(infoPanel);
player.setCornerRadius(0);
player.onPlayPause = [this]() { togglePlaying(); };
player.onNext = [this]() { playAdjacent(1); };
player.onPrevious = [this]() { playAdjacent(-1); };
player.seek.setOnSeek([this](halnf position) { mDevice.setProgress(position); });
player.volume.setOnChanged([this](halnf volume) {
mVolume = volume;
mDevice.setVolume(volume);
});
addChild(dock);
addChild(player);
startTicking();
}
void LibraryWidget::buildMenus(MenuBar& menuBar) {
fileMenu.addItem("Rescan files", [this]() {
mLibrary.checkExisting();
applyFilter();
});
viewMenu.addToggle("Info panel", [this]() { return infoPanel.isOpen(); }, [this]() { dock.togglePanel(infoPanel); });
viewMenu.addToggle(
"Loved only", [this]() { return mFilter.lovedOnly; }, [this]() { lovedOnly.setChecked(!lovedOnly.isChecked()); }
);
playbackMenu.addItem("Play / Pause", [this]() { togglePlaying(); });
playbackMenu.addItem("Next", [this]() { playAdjacent(1); });
playbackMenu.addItem("Previous", [this]() { playAdjacent(-1); });
menuBar.addMenu("File", fileMenu);
menuBar.addMenu("View", viewMenu);
menuBar.addMenu("Playback", playbackMenu);
}
void LibraryWidget::rebuildTracks() {
mSelected = nullptr;
mTracks.clearSelection();
applyFilter();
}
void LibraryWidget::applyFilter() {
mVisible.clear();
for (const Track& track : mLibrary.mTraks) {
if (mFilter.accepts(track)) mVisible.push_back(&track);
}
ListView::Model model;
model.count = mVisible.size();
model.fill = [this](size_t index, ListRow& row) {
const Track& track = *mVisible[index];
row.setTitle(track.mName);
row.setSubtitle(track.mArtist);
// The playing track is marked with the play glyph; the others keep the room so titles line up.
row.setLeading(&track == mPlaying ? ListRow::Leading::glyphOf(Glyph::Play) : ListRow::Leading::space());
row.setMuted(!track.mExists);
};
mTracks.setModel(std::move(model));
// The selection follows the selected track through filtering.
mTracks.clearSelection();
for (size_t index = 0; index < mVisible.size(); index++) {
if (mVisible[index] == mSelected) mTracks.select(index);
}
count.setText(std::to_string(mVisible.size()) + " of " + std::to_string(mLibrary.mTraks.size()) + " tracks");
}
void LibraryWidget::select(size_t index) {
if (index >= mVisible.size()) return;
mSelected = mVisible[index];
if (mTracks.current() != index) mTracks.select(index);
refreshInfo();
}
void LibraryWidget::refreshInfo() {
const Track* shown = mSelected ? mSelected : mPlaying;
info.showTrack(shown, shown && shown == mPlaying && !mPaused);
}
void LibraryWidget::play(const Track& track) {
if (!mDevice.start(track.mId)) {
player.title.setText("Can't play \"" + track.mName + "\"");
return;
}
mPlaying = &track;
mPaused = false;
mDevice.setVolume(mVolume);
mDevice.resume();
mNowPlaying = {
track.mName, track.mArtist, track.mAlbum, (halnf) track.mTotalTime / kMillisecondsPerSecond, track.mLoved
};
player.setTrack(&mNowPlaying);
player.setPlaying(true);
mTracks.refresh();
refreshInfo();
}
void LibraryWidget::togglePlaying() {
if (!mPlaying) {
if (mSelected) play(*mSelected);
else if (!mVisible.empty()) play(*mVisible.front());
return;
}
mPaused = !mPaused;
if (mPaused) mDevice.pause();
else mDevice.resume();
player.setPlaying(!mPaused);
refreshInfo();
}
void LibraryWidget::playAdjacent(int direction) {
if (mVisible.empty()) return;
long index = -1;
if (mPlaying) {
for (size_t position = 0; position < mVisible.size(); position++) {
if (mVisible[position] == mPlaying) index = (long) position;
}
}
const long size = (long) mVisible.size();
const long target = index < 0 ? 0 : ((index + direction) % size + size) % size;
select((size_t) target);
play(*mVisible[(size_t) target]);
}
void LibraryWidget::onEvent(Event& event) {
if (event.type() != Event::Type::KeyDown || !event.isKey(Key::Space)) return;
// Space plays or pauses anywhere but in the search field, where it is a space.
if (search.hasKeyboardFocus()) return;
togglePlaying();
event.accept();
}
void LibraryWidget::onTick(halnf) {
if (!mPlaying) return;
if (!player.seek.isScrubbing()) player.setPosition(mDevice.progress());
player.setBuffered(mDevice.loaded());
if (!mPaused && mDevice.progress() >= 1.0f) playAdjacent(1);
}