aseprite/src/app/ui/file_list.cpp
David Capello 104f8a10cf Fix crash using invalid index in FileItemList (fix #3181)
There is a state where m_selected is pointing to an item that doesn't
exist in the current list of items. We didn't detect this on Debug
mode yet, but we've received one Sentry crash report about it.
2022-02-17 10:36:18 -03:00

920 lines
23 KiB
C++

// Aseprite
// Copyright (C) 2019-2022 Igara Studio S.A.
// Copyright (C) 2001-2018 David Capello
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "app/ui/file_list.h"
#include "app/modules/gfx.h"
#include "app/thumbnail_generator.h"
#include "app/ui/skin/skin_theme.h"
#include "base/clamp.h"
#include "base/string.h"
#include "base/time.h"
#include "os/font.h"
#include "os/surface.h"
#include "ui/ui.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#define ISEARCH_KEYPRESS_INTERVAL_MSECS 500
namespace app {
using namespace app::skin;
using namespace gfx;
using namespace ui;
FileList::FileList()
: Widget(kGenericWidget)
, m_currentFolder(FileSystemModule::instance()->getRootFileItem())
, m_req_valid(false)
, m_selected(nullptr)
, m_isearchClock(0)
, m_generateThumbnailTimer(200, this)
, m_monitoringTimer(50, this)
, m_itemToGenerateThumbnail(nullptr)
, m_multiselect(false)
, m_zoom(1.0)
, m_itemsPerRow(0)
{
setFocusStop(true);
setDoubleBuffered(true);
m_generateThumbnailTimer.Tick.connect(&FileList::onGenerateThumbnailTick, this);
m_monitoringTimer.Tick.connect(&FileList::onMonitoringTick, this);
m_monitoringTimer.start();
regenerateList();
}
FileList::~FileList()
{
// Stop timers.
m_generateThumbnailTimer.stop();
m_monitoringTimer.stop();
// Stop workers creating thumbnails.
ThumbnailGenerator::instance()->stopAllWorkers();
}
void FileList::setExtensions(const base::paths& extensions)
{
m_exts = extensions;
// Refresh
if (isVisible())
setCurrentFolder(m_currentFolder);
}
void FileList::setCurrentFolder(IFileItem* folder)
{
ASSERT(folder != NULL);
ASSERT(folder->isBrowsable());
m_currentFolder = folder;
m_req_valid = false;
m_selected = nullptr;
regenerateList();
// As now we are in other folder, we can stop the generation of all
// thumbnails.
ThumbnailGenerator::instance()->stopAllWorkers();
// select first folder
if (!m_list.empty() && m_list.front()->isBrowsable())
selectIndex(0);
// Emit "CurrentFolderChanged" event.
onCurrentFolderChanged();
invalidate();
View::getView(this)->updateView();
}
FileItemList FileList::selectedFileItems() const
{
ASSERT(m_selectedItems.size() == m_list.size());
FileItemList result;
for (int i=0; i<int(m_list.size()); ++i) {
if (m_selectedItems[i])
result.push_back(m_list[i]);
}
return result;
}
void FileList::deselectedFileItems()
{
if (!m_multiselect)
return;
bool redraw = false;
for (int i=0; i<int(m_list.size()); ++i) {
if (m_selected == m_list[i]) {
if (!m_selectedItems[i]) {
m_selectedItems[i] = true;
redraw = true;
}
}
else if (m_selectedItems[i]) {
m_selectedItems[i] = false;
redraw = true;
}
}
if (redraw)
invalidate();
}
void FileList::setMultipleSelection(bool multiple)
{
m_multiselect = multiple;
}
void FileList::goUp()
{
IFileItem* folder = m_currentFolder;
IFileItem* parent = folder->parent();
if (parent) {
setCurrentFolder(parent);
m_selected = folder;
deselectedFileItems();
// Make the selected item visible.
makeSelectedFileitemVisible();
}
}
void FileList::setZoom(const double zoom)
{
m_zoom = base::clamp(zoom, 1.0, 8.0);
m_req_valid = false;
// if (auto view = View::getView(this))
recalcAllFileItemInfo();
}
void FileList::animateToZoom(const double zoom)
{
m_fromZoom = m_zoom;
m_toZoom = zoom;
startAnimation(ANI_ZOOM, 10);
}
bool FileList::onProcessMessage(Message* msg)
{
switch (msg->type()) {
case kMouseDownMessage:
captureMouse();
case kMouseMoveMessage:
if (hasCapture()) {
MouseMessage* mouseMsg = static_cast<MouseMessage*>(msg);
IFileItem* oldSelected = m_selected;
m_selected = nullptr;
// Mouse position in client coordinates
gfx::Point mousePos = mouseMsg->position() - bounds().origin();
// rows
int i = 0;
for (auto begin=m_list.begin(), end=m_list.end(), it=begin;
it!=end; ++it, ++i) {
IFileItem* fi = *it;
ItemInfo info = getFileItemInfo(i);
if ((info.bounds.contains(mousePos)) ||
(isListView() &&
((it == begin && mousePos.y < info.bounds.y) ||
(it == end-1 && mousePos.y >= info.bounds.y2())))) {
m_selected = fi;
if (m_multiselect &&
oldSelected != fi &&
m_selectedItems.size() == m_list.size()) {
if (msg->shiftPressed() ||
msg->ctrlPressed() ||
msg->cmdPressed()) {
m_selectedItems[i] = !m_selectedItems[i];
}
else {
std::fill(m_selectedItems.begin(),
m_selectedItems.end(), false);
m_selectedItems[i] = true;
}
invalidate();
}
makeSelectedFileitemVisible();
break;
}
}
if (oldSelected != m_selected) {
if (hasThumbnailsPerItem())
generateThumbnailForFileItem(m_selected);
else
delayThumbnailGenerationForSelectedItem();
invalidate();
// Emit "FileSelected" event.
onFileSelected();
}
}
break;
case kMouseUpMessage:
if (hasCapture()) {
releaseMouse();
}
break;
case kKeyDownMessage:
if (hasFocus()) {
KeyMessage* keyMsg = static_cast<KeyMessage*>(msg);
KeyScancode scancode = keyMsg->scancode();
int unicodeChar = keyMsg->unicodeChar();
int select = selectedIndex();
View* view = View::getView(this);
int bottom = m_list.size();
switch (scancode) {
case kKeyUp:
if (select >= 0)
select -= m_itemsPerRow;
else
select = 0;
break;
case kKeyDown:
if (select >= 0)
select += m_itemsPerRow;
else
select = 0;
break;
case kKeyHome:
select = 0;
break;
case kKeyEnd:
select = bottom-1;
break;
case kKeyPageUp:
case kKeyPageDown: {
int sgn = (scancode == kKeyPageUp) ? -1: 1;
gfx::Rect vp = view->viewportBounds();
if (select < 0)
select = 0;
select += sgn * vp.h / (textHeight()+4*guiscale());
break;
}
case kKeyLeft:
case kKeyRight: {
const int delta = (scancode == kKeyLeft ? -1: 1);
if (isIconView()) {
if (select >= 0)
select += delta;
else
select = 0;
}
else if (select >= 0) {
gfx::Rect vp = view->viewportBounds();
gfx::Point scroll = view->viewScroll();
scroll.x += vp.w/2*delta;
view->setViewScroll(scroll);
}
break;
}
case kKeyEnter:
case kKeyEnterPad:
if (m_selected) {
if (m_selected->isBrowsable()) {
setCurrentFolder(m_selected);
return true;
}
if (m_selected->isFolder()) {
// Do nothing (is a folder but not browseable).
return true;
}
else {
// Emit "FileAccepted" event.
onFileAccepted();
return true;
}
}
else
return Widget::onProcessMessage(msg);
case kKeyBackspace:
goUp();
return true;
default:
if (unicodeChar == ' ' ||
(std::tolower(unicodeChar) >= 'a' &&
std::tolower(unicodeChar) <= 'z') ||
(unicodeChar >= '0' &&
unicodeChar <= '9')) {
if ((base::current_tick() - m_isearchClock) > ISEARCH_KEYPRESS_INTERVAL_MSECS)
m_isearch.clear();
m_isearch.push_back(unicodeChar);
int i, chrs = m_isearch.size();
FileItemList::iterator
link = m_list.begin() + ((select >= 0) ? select: 0);
for (i=std::max(select, 0); i<bottom; ++i, ++link) {
IFileItem* fi = *link;
if (base::utf8_icmp(fi->displayName(), m_isearch, chrs) == 0) {
select = i;
break;
}
}
m_isearchClock = base::current_tick();
// Go to selectIndex...
}
else
return Widget::onProcessMessage(msg);
}
if (bottom > 0)
selectIndex(base::clamp(select, 0, bottom-1));
return true;
}
break;
case kMouseWheelMessage: {
View* view = View::getView(this);
if (view) {
// Zoom
if (msg->ctrlPressed() || // TODO configurable
msg->cmdPressed()) {
gfx::Point delta = static_cast<MouseMessage*>(msg)->wheelDelta();
const bool precise = static_cast<MouseMessage*>(msg)->preciseWheel();
double dz = delta.x + delta.y;
if (precise) {
dz /= 1.5;
if (dz < -1.0) dz = -1.0;
else if (dz > 1.0) dz = 1.0;
}
setZoom(zoom() - dz);
break;
}
else {
gfx::Point scroll = view->viewScroll();
if (static_cast<MouseMessage*>(msg)->preciseWheel())
scroll += static_cast<MouseMessage*>(msg)->wheelDelta();
else
scroll += static_cast<MouseMessage*>(msg)->wheelDelta() * 3*(textHeight()+4*guiscale());
view->setViewScroll(scroll);
}
}
break;
}
case kDoubleClickMessage:
if (m_selected) {
if (m_selected->isBrowsable()) {
setCurrentFolder(m_selected);
return true;
}
else {
onFileAccepted(); // Emit "FileAccepted" event.
return true;
}
}
break;
case kTouchMagnifyMessage: {
setZoom(zoom() + 4.0*static_cast<ui::TouchMessage*>(msg)->magnification());
break;
}
}
return Widget::onProcessMessage(msg);
}
void FileList::onPaint(ui::PaintEvent& ev)
{
Graphics* g = ev.graphics();
SkinTheme* theme = static_cast<SkinTheme*>(this->theme());
gfx::Rect bounds = clientBounds();
g->fillRect(theme->colors.background(), bounds);
// g->fillRect(bgcolor, gfx::Rect(bounds.x, y, bounds.w, itemSize.h));
int i = 0, selectedIndex = -1;
for (IFileItem* fi : m_list) {
if (m_selected != fi) {
paintItem(g, fi, i);
}
else {
selectedIndex = i;
}
++i;
}
// Paint main selected index (so if the filename label is bigger it
// will appear over other items).
if (m_selected) {
ASSERT(selectedIndex >= 0);
if (selectedIndex >= 0)
paintItem(g, m_selected, selectedIndex);
else {
// Strange run-time state where the "m_selected" is not in the
// list. The previous assert should fail on Debug so this is
// here only for Release mode.
return;
}
}
// Draw main thumbnail for the selected item when there are no
// thumbnails per item.
if (isListView() &&
m_selected &&
m_selected->getThumbnail()) {
gfx::Rect tbounds = mainThumbnailBounds();
tbounds.enlarge(1);
g->drawRect(gfx::rgba(0, 0, 0, 64), tbounds);
tbounds.shrink(1);
os::SurfaceRef thumbnail = m_selected->getThumbnail();
g->drawRgbaSurface(thumbnail.get(),
gfx::Rect(0, 0, thumbnail->width(), thumbnail->height()),
tbounds);
}
}
void FileList::paintItem(ui::Graphics* g, IFileItem* fi, const int i)
{
ItemInfo info = getFileItemInfo(i);
if ((g->getClipBounds() & info.bounds).isEmpty())
return;
SkinTheme* theme = static_cast<SkinTheme*>(this->theme());
const bool evenRow = ((i & 1) == 0);
gfx::Rect tbounds = info.thumbnail;
gfx::Color bgcolor;
gfx::Color fgcolor;
if ((!m_multiselect && fi == m_selected) ||
(m_multiselect &&
m_selectedItems.size() == m_list.size() &&
m_selectedItems[i])) {
fgcolor = theme->colors.filelistSelectedRowText();
bgcolor = theme->colors.filelistSelectedRowFace();
}
else {
bgcolor = evenRow ? theme->colors.filelistEvenRowFace():
theme->colors.filelistOddRowFace();
if (fi->isFolder() && !fi->isBrowsable())
fgcolor = theme->colors.filelistDisabledRowText();
else
fgcolor = evenRow ? theme->colors.filelistEvenRowText():
theme->colors.filelistOddRowText();
}
// Item background
g->fillRect(bgcolor, info.bounds);
gfx::Rect textBounds = info.text;
// Folder icon or thumbnail
os::SurfaceRef thumbnail = nullptr;
if (fi->isFolder()) {
if (isListView()) {
thumbnail = theme->parts.folderIconSmall()->bitmapRef(0);
tbounds = textBounds;
tbounds.x += 2*guiscale();
tbounds.w = tbounds.h;
textBounds.x += tbounds.x2();
}
else {
thumbnail =
(m_zoom < 4.0 ?
theme->parts.folderIconMedium()->bitmapRef(0):
theme->parts.folderIconBig()->bitmapRef(0));
}
}
else {
thumbnail = fi->getThumbnail();
}
// item name
if (isIconView() && textBounds.w > info.bounds.w) {
g->drawAlignedUIText(
fi->displayName().c_str(),
fgcolor, bgcolor,
(textBounds & gfx::Rect(info.bounds).shrink(2*guiscale())),
ui::CENTER | ui::TOP | ui::CHARWRAP);
}
else {
g->drawText(
fi->displayName().c_str(),
fgcolor, bgcolor,
gfx::Point(textBounds.x+2*guiscale(),
textBounds.y+2*guiscale()));
}
// Draw thumbnail progress bar
double progress = fi->getThumbnailProgress();
if (isIconView() && !thumbnail) {
if (progress == 0.0)
generateThumbnailForFileItem(fi);
}
if (!tbounds.isEmpty()) {
if (thumbnail) {
tbounds =
gfx::Rect(0, 0, thumbnail->width(), thumbnail->height())
.fitIn(tbounds);
if (!fi->isFolder()) {
g->drawRect(gfx::rgba(0, 0, 0, 64), tbounds);
tbounds.shrink(1);
}
g->drawRgbaSurface(thumbnail.get(),
gfx::Rect(0, 0, thumbnail->width(), thumbnail->height()),
tbounds);
}
else {
tbounds = gfx::Rect(0, 0, 20*guiscale(), 2+4*(8.0-m_zoom)/8.0*guiscale())
.fitIn(tbounds);
// Start thumbnail generation for this item
generateThumbnailForFileItem(fi);
theme->paintProgressBar(g, tbounds, progress);
}
}
}
gfx::Rect FileList::mainThumbnailBounds()
{
gfx::Rect result;
if (!m_selected)
return result;
os::SurfaceRef thumbnail = m_selected->getThumbnail();
if (!thumbnail)
return result;
ItemInfo info = getFileItemInfo(selectedIndex());
if (!info.thumbnail.isEmpty()) // There is thumbnail per item
return result;
View* view = View::getView(this);
gfx::Rect vp = view->viewportBounds();
int x = vp.x+vp.w - 2*guiscale() - thumbnail->width();
int y = info.bounds.center().y - thumbnail->height()/2 + bounds().y;
y = base::clamp(y, vp.y+2*guiscale(), vp.y+vp.h-3*guiscale()-thumbnail->height());
x -= bounds().x;
y -= bounds().y;
return gfx::Rect(x, y, thumbnail->width(), thumbnail->height());
}
void FileList::onSizeHint(SizeHintEvent& ev)
{
if (!m_req_valid) {
gfx::Rect req;
// rows
for (int i=0; i<int(m_list.size()); ++i) {
ItemInfo info = getFileItemInfo(i);
req |= info.bounds;
}
m_req_valid = true;
m_req_w = req.w;
m_req_h = req.h;
}
ev.setSizeHint(Size(m_req_w, m_req_h));
}
void FileList::onFileSelected()
{
FileSelected();
}
void FileList::onFileAccepted()
{
FileAccepted();
}
void FileList::onCurrentFolderChanged()
{
CurrentFolderChanged();
}
void FileList::onMonitoringTick()
{
auto start = base::current_tick();
while (!m_generateThumbnailsForTheseItems.empty() &&
// No more than 200ms launching thumbnail generators
base::current_tick() - start < 200) {
auto fi = m_generateThumbnailsForTheseItems.front();
m_generateThumbnailsForTheseItems.pop_front();
ThumbnailGenerator::instance()->generateThumbnail(fi);
}
if (ThumbnailGenerator::instance()->checkWorkers())
invalidate();
}
void FileList::onGenerateThumbnailTick()
{
m_generateThumbnailTimer.stop();
auto fi = m_itemToGenerateThumbnail;
if (fi)
generateThumbnailForFileItem(fi);
}
void FileList::recalcAllFileItemInfo()
{
View* view = View::getView(this);
if (view)
view->setScrollableSize(gfx::Size(1, view->bounds().h), false);
m_info.resize(m_list.size());
if (m_info.empty()) {
m_itemsPerRow = 0;
return;
}
for (int i=0; i<int(m_info.size()); ++i)
m_info[i] = calcFileItemInfo(i);
m_itemsPerRow = 1;
// Add the vertical scrollbar space
if (isListView()) {
if (view) {
view->updateView();
if (!view->verticalBar()->isVisible()) {
gfx::Rect vp = view->viewportBounds();
for (auto& info : m_info) {
info.bounds.w = vp.w;
}
}
}
}
// Redistribute items in X axis
else if (isIconView()) {
int maxWidth = 0;
int maxTextWidth = 0;
for (const auto& info : m_info) {
int w = std::min(info.bounds.w, info.thumbnail.w*2);
maxWidth = std::max(maxWidth, w);
maxTextWidth = std::max(maxTextWidth, info.text.w);
}
if (maxWidth == 0)
return;
gfx::Size vp = (view ? view->viewportBounds().size(): size());
int itemsPerRow = vp.w / maxWidth;
if (itemsPerRow < 3) itemsPerRow = 3;
int itemWidth = vp.w / itemsPerRow;
int i = 0;
for (int y=0; i<int(m_info.size()); ) {
int h = 0;
int j = 0;
int x = 0;
for (; j<itemsPerRow && i<int(m_info.size()); ++j, ++i, x += itemWidth) {
auto& info = m_info[i];
int deltax = x - info.bounds.x;
int deltay = y - info.bounds.y;
info.bounds.x += deltax;
info.bounds.y += deltay;
info.bounds.w = itemWidth;
if (maxTextWidth > itemWidth) {
info.bounds.h += info.text.h;
info.text.h += info.text.h;
}
info.text.x = info.bounds.x + info.bounds.w/2 - info.text.w/2;
info.text.y += deltay;
info.thumbnail.x = info.bounds.x + info.bounds.w/2 - info.thumbnail.w/2;
info.thumbnail.y += deltay;
h = info.bounds.h;
}
if (h)
y += h;
else
break;
}
m_itemsPerRow = itemsPerRow;
}
invalidate();
if (view) {
view->updateView();
makeSelectedFileitemVisible();
}
}
FileList::ItemInfo FileList::calcFileItemInfo(int i) const
{
ASSERT(i >= 0 && i < int(m_list.size()));
const bool withThumbnails = hasThumbnailsPerItem();
IFileItem* fi = m_list[i];
int len = 0;
if (fi->isFolder() && isListView()) {
SkinTheme* theme = static_cast<SkinTheme*>(this->theme());
len += theme->parts.folderIconSmall()->bitmap(0)->width() + 2*guiscale();
}
len += font()->textLength(fi->displayName().c_str());
int textHeight = this->textHeight() + 4*guiscale();
int rowHeight = textHeight + (withThumbnails ? 8*m_zoom+2*guiscale(): 0);
ItemInfo info;
info.text = gfx::Rect(0, 0+rowHeight*i, len+4*guiscale(), textHeight);
if (withThumbnails) {
info.thumbnail = gfx::Rect(0, info.text.y,
8*m_zoom*guiscale(),
8*m_zoom*guiscale());
info.text.y += info.thumbnail.h + 2*guiscale();
}
info.bounds = info.text | info.thumbnail;
if (withThumbnails) {
info.text.x = info.bounds.x + info.bounds.w/2 - info.text.w/2;
info.thumbnail.x = info.bounds.x + info.bounds.w/2 - info.thumbnail.w/2;
}
else {
info.bounds.x = 0;
if (View* view = View::getView(this))
info.bounds.w = view->viewportBounds().w;
else
info.bounds.w = bounds().w;
}
return info;
}
FileList::ItemInfo FileList::getFileItemInfo(int i) const
{
ASSERT(i >= 0 && i < int(m_info.size()));
if (i >= 0 && i < int(m_info.size()))
return m_info[i];
else
return ItemInfo();
}
void FileList::makeSelectedFileitemVisible()
{
int i = selectedIndex();
if (i < 0)
return;
View* view = View::getView(this);
gfx::Rect vp = view->viewportBounds();
gfx::Point scroll = view->viewScroll();
ItemInfo info = getFileItemInfo(i);
if (info.bounds.x+bounds().x <= vp.x)
scroll.x = info.bounds.x;
else if (info.bounds.x+bounds().x > vp.x2() - info.bounds.w)
scroll.x = info.bounds.x - vp.w + info.bounds.w;
if (info.bounds.y+bounds().y < vp.y)
scroll.y = info.bounds.y;
else if (info.bounds.y+bounds().y > vp.y2() - info.bounds.h)
scroll.y = info.bounds.y - vp.h + info.bounds.h;
view->setViewScroll(scroll);
}
void FileList::regenerateList()
{
// get the children of the current folder
m_list = m_currentFolder->children();
// filter the list by the available extensions
if (!m_exts.empty()) {
for (FileItemList::iterator
it=m_list.begin();
it!=m_list.end(); ) {
IFileItem* fileitem = *it;
if (fileitem->isHidden())
it = m_list.erase(it);
else if (!fileitem->isFolder() &&
!fileitem->hasExtension(m_exts)) {
it = m_list.erase(it);
}
else
++it;
}
}
recalcAllFileItemInfo();
if (m_multiselect && !m_list.empty()) {
m_selectedItems.resize(m_list.size());
deselectedFileItems();
}
else
m_selectedItems.clear();
}
int FileList::selectedIndex() const
{
for (auto it = m_list.begin(), end = m_list.end();
it != end; ++it) {
if (*it == m_selected)
return it - m_list.begin();
}
return -1;
}
void FileList::selectIndex(int index)
{
IFileItem* oldSelected = m_selected;
m_selected = m_list.at(index);
deselectedFileItems();
if (oldSelected != m_selected) {
makeSelectedFileitemVisible();
invalidate();
// Emit "FileSelected" event.
onFileSelected();
}
delayThumbnailGenerationForSelectedItem();
}
void FileList::generateThumbnailForFileItem(IFileItem* fi)
{
if (fi && animation() == ANI_NONE) {
auto it = std::find(m_generateThumbnailsForTheseItems.begin(),
m_generateThumbnailsForTheseItems.end(), fi);
if (it != m_generateThumbnailsForTheseItems.end())
m_generateThumbnailsForTheseItems.erase(it);
m_generateThumbnailsForTheseItems.push_front(fi);
}
}
void FileList::delayThumbnailGenerationForSelectedItem()
{
if (m_selected &&
!m_selected->isFolder() &&
!m_selected->getThumbnail()) {
m_itemToGenerateThumbnail = m_selected;
m_generateThumbnailTimer.start();
}
}
void FileList::onAnimationStop(int animation)
{
if (animation == ANI_ZOOM)
setZoom(m_toZoom);
}
void FileList::onAnimationFrame()
{
if (animation() == ANI_ZOOM)
setZoom(inbetween(m_fromZoom, m_toZoom, animationTime()));
}
} // namespace app