Table Browser: Support extended selections

Support of extended selections (non-contiguous cell selections using
Control+Click). The following changes were necessary:

- Copy to clipboard iterates over rows and columns and leaves holes for
  non-rectangular selections as empty cells (HTML) or as NULL (SQL).
  Fix: Additionally the SQL copy uses NULL when the cell has NULL, not ''.
- Shortcuts for column and row selection take into account non-contiguous
  cells and only select those columns/rows.
- The legend in the status line counts correctly non-contiguous rows or
  columns.
- Delete Record has been adjusted so only contiguous selected cells are
  removed in a single step. Fix: selecting line after removing has been
  deleted since the standard behaviour is giving better result.
- ExtendedSelection has been enabled in TableBrowser, it was already
  enabled in Execute SQL table-widget.

Possible improvements: pasting from the internal clipboard does not keep
the layout of copied non-rectangular selections.

See issues #1104 and #2638
This commit is contained in:
mgrojo
2021-03-26 18:40:38 +01:00
parent 93a5c2d0ca
commit c5199f559d
4 changed files with 119 additions and 85 deletions

View File

@@ -418,13 +418,13 @@ ExtendedTableWidget::ExtendedTableWidget(QWidget* parent) :
connect(selectColumnShortcut, &QShortcut::activated, this, [this]() {
if(!hasFocus() || selectionModel()->selectedIndexes().isEmpty())
return;
selectionModel()->select(QItemSelection(selectionModel()->selectedIndexes().first(), selectionModel()->selectedIndexes().last()), QItemSelectionModel::Select | QItemSelectionModel::Columns);
selectionModel()->select(selectionModel()->selection(), QItemSelectionModel::Select | QItemSelectionModel::Columns);
});
QShortcut* selectRowShortcut = new QShortcut(QKeySequence("Shift+Space"), this);
connect(selectRowShortcut, &QShortcut::activated, this, [this]() {
if(!hasFocus() || selectionModel()->selectedIndexes().isEmpty())
return;
selectionModel()->select(QItemSelection(selectionModel()->selectedIndexes().first(), selectionModel()->selectedIndexes().last()), QItemSelectionModel::Select | QItemSelectionModel::Rows);
selectionModel()->select(selectionModel()->selection(), QItemSelectionModel::Select | QItemSelectionModel::Rows);
});
// Set up frozen columns child widget
@@ -573,6 +573,13 @@ void ExtendedTableWidget::copyMimeData(const QModelIndexList& fromIndices, QMime
htmlResult.append("<style type=\"text/css\">br{mso-data-placement:same-cell;}</style></head><body>"
"<table border=1 cellspacing=0 cellpadding=2>");
// Insert the columns in a set, since they could be non-contiguous.
std::set<int> colsInIndexes, rowsInIndexes;
for(const QModelIndex & idx : indices) {
colsInIndexes.insert(idx.column());
rowsInIndexes.insert(idx.row());
}
int currentRow = indices.first().row();
const QString fieldSepText = "\t";
@@ -586,10 +593,11 @@ void ExtendedTableWidget::copyMimeData(const QModelIndexList& fromIndices, QMime
// Table headers
if (withHeaders || inSQL) {
htmlResult.append("<tr><th>");
int firstColumn = indices.front().column();
for(int i = firstColumn; i <= indices.back().column(); i++) {
QByteArray headerText = model()->headerData(i, Qt::Horizontal, Qt::EditRole).toByteArray();
if (i != firstColumn) {
int firstColumn = *colsInIndexes.begin();
for(int col : colsInIndexes) {
QByteArray headerText = model()->headerData(col, Qt::Horizontal, Qt::EditRole).toByteArray();
if (col != firstColumn) {
result.append(fieldSepText);
htmlResult.append("</th><th>");
sqlInsertStatement.append(", ");
@@ -604,79 +612,89 @@ void ExtendedTableWidget::copyMimeData(const QModelIndexList& fromIndices, QMime
sqlInsertStatement.append(") VALUES (");
}
// Table data rows
for(const QModelIndex& index : indices) {
QFont font;
font.fromString(index.data(Qt::FontRole).toString());
const QString fontStyle(font.italic() ? "italic" : "normal");
const QString fontWeigth(font.bold() ? "bold" : "normal");
const QString fontDecoration(font.underline() ? " text-decoration: underline;" : "");
const QColor bgColor(index.data(Qt::BackgroundRole).toString());
const QColor fgColor(index.data(Qt::ForegroundRole).toString());
const Qt::Alignment align(index.data(Qt::TextAlignmentRole).toInt());
const QString textAlign(CondFormat::alignmentTexts().at(CondFormat::fromCombinedAlignment(align)).toLower());
const QString style = QString("font-family: '%1'; font-size: %2pt; font-style: %3; font-weight: %4;%5 "
"background-color: %6; color: %7; text-align: %8").arg(
font.family().toHtmlEscaped(),
QString::number(font.pointSize()),
fontStyle,
fontWeigth,
fontDecoration,
bgColor.name(),
fgColor.name(),
textAlign);
// Iterate over rows x cols checking if the index actually exists when needed, in order
// to support non-rectangular selections.
for(const int row : rowsInIndexes) {
for(const int column : colsInIndexes) {
// Separators. For first cell, only opening table row tags must be added for the HTML and nothing for the text version.
if (indices.first() == index) {
htmlResult.append(QString("<tr><td style=\"%1\">").arg(style));
sqlResult.append(sqlInsertStatement);
} else if (index.row() != currentRow) {
result.append(rowSepText);
htmlResult.append(QString("</td></tr><tr><td style=\"%1\">").arg(style));
sqlResult.append(");" + rowSepText + sqlInsertStatement);
} else {
result.append(fieldSepText);
htmlResult.append(QString("</td><td style=\"%1\">").arg(style));
sqlResult.append(", ");
}
currentRow = index.row();
const QModelIndex index = indices.first().sibling(row, column);
QString style;
if(indices.contains(index)) {
QFont font;
font.fromString(index.data(Qt::FontRole).toString());
const QString fontStyle(font.italic() ? "italic" : "normal");
const QString fontWeigth(font.bold() ? "bold" : "normal");
const QString fontDecoration(font.underline() ? " text-decoration: underline;" : "");
const QColor bgColor(index.data(Qt::BackgroundRole).toString());
const QColor fgColor(index.data(Qt::ForegroundRole).toString());
const Qt::Alignment align(index.data(Qt::TextAlignmentRole).toInt());
const QString textAlign(CondFormat::alignmentTexts().at(CondFormat::fromCombinedAlignment(align)).toLower());
style = QString("style=\"font-family: '%1'; font-size: %2pt; font-style: %3; font-weight: %4;%5 "
"background-color: %6; color: %7; text-align: %8\"").arg(
font.family().toHtmlEscaped(),
QString::number(font.pointSize()),
fontStyle,
fontWeigth,
fontDecoration,
bgColor.name(),
fgColor.name(),
textAlign);
}
QImage img;
QVariant bArrdata = index.data(Qt::EditRole);
// Separators. For first cell, only opening table row tags must be added for the HTML and nothing for the text version.
if (index.row() == *rowsInIndexes.begin() && index.column() == *colsInIndexes.begin()) {
htmlResult.append(QString("<tr><td %1>").arg(style));
sqlResult.append(sqlInsertStatement);
} else if (index.row() != currentRow) {
result.append(rowSepText);
htmlResult.append(QString("</td></tr><tr><td %1>").arg(style));
sqlResult.append(");" + rowSepText + sqlInsertStatement);
} else {
result.append(fieldSepText);
htmlResult.append(QString("</td><td %1>").arg(style));
sqlResult.append(", ");
}
// Table cell data: image? Store it as an embedded image in HTML
if (!inSQL && img.loadFromData(bArrdata.toByteArray()))
{
QByteArray ba;
QBuffer buffer(&ba);
buffer.open(QIODevice::WriteOnly);
img.save(&buffer, "PNG");
buffer.close();
currentRow = index.row();
QString imageBase64 = ba.toBase64();
htmlResult.append("<img src=\"data:image/png;base64,");
htmlResult.append(imageBase64);
result.append(QString());
htmlResult.append("\" alt=\"Image\">");
} else {
QByteArray text;
if (!m->isBinary(index)) {
text = bArrdata.toByteArray();
QImage img;
QVariant bArrdata = indices.contains(index) ? index.data(Qt::EditRole) : QVariant();
// Table cell data: text
if (text.contains('\n') || text.contains('\t'))
htmlResult.append("<pre>" + QString(text).toHtmlEscaped() + "</pre>");
else
htmlResult.append(QString(text).toHtmlEscaped());
// Table cell data: image? Store it as an embedded image in HTML
if (!inSQL && img.loadFromData(bArrdata.toByteArray()))
{
QByteArray ba;
QBuffer buffer(&ba);
buffer.open(QIODevice::WriteOnly);
img.save(&buffer, "PNG");
buffer.close();
result.append(text);
sqlResult.append(sqlb::escapeString(text));
} else
// Table cell data: binary. Save as BLOB literal in SQL
sqlResult.append( "X'" + bArrdata.toByteArray().toHex() + "'" );
QString imageBase64 = ba.toBase64();
htmlResult.append("<img src=\"data:image/png;base64,");
htmlResult.append(imageBase64);
result.append(QString());
htmlResult.append("\" alt=\"Image\">");
} else {
if (bArrdata.isNull()) {
sqlResult.append("NULL");
} else if(!m->isBinary(index)) {
QByteArray text = bArrdata.toByteArray();
// Table cell data: text
if (text.contains('\n') || text.contains('\t'))
htmlResult.append("<pre>" + QString(text).toHtmlEscaped() + "</pre>");
else
htmlResult.append(QString(text).toHtmlEscaped());
result.append(text);
sqlResult.append(sqlb::escapeString(text));
} else
// Table cell data: binary. Save as BLOB literal in SQL
sqlResult.append( "X'" + bArrdata.toByteArray().toHex() + "'" );
}
}
}
sqlResult.append(");");
if ( inSQL )
@@ -1022,6 +1040,14 @@ std::unordered_set<size_t> ExtendedTableWidget::colsInSelection() const
return colsInSelection;
}
std::set<size_t> ExtendedTableWidget::rowsInSelection() const
{
std::set<size_t> rowsInSelection;
for(const QModelIndex & idx : selectedIndexes())
rowsInSelection.insert(static_cast<size_t>(idx.row()));
return rowsInSelection;
}
void ExtendedTableWidget::cellClicked(const QModelIndex& index)
{
// If Ctrl-Shift is pressed try to jump to the row referenced by the foreign key of the clicked cell

View File

@@ -5,6 +5,7 @@
#include <QStyledItemDelegate>
#include <QSortFilterProxyModel>
#include <unordered_set>
#include <set>
#include "sql/Query.h"
@@ -60,8 +61,10 @@ public:
public:
// Get set of selected columns (all cells in column has to be selected)
std::unordered_set<size_t> selectedCols() const;
// Get set of columns traversed by selection (only some cells in column has to be selected)
// Get set of columns traversed by selection (only some cells in column have to be selected)
std::unordered_set<size_t> colsInSelection() const;
// Get set of ordered rows traversed by selection (only some cells in row have to be selected)
std::set<size_t> rowsInSelection() const;
int numVisibleRows() const;

View File

@@ -474,9 +474,9 @@ void TableBrowser::refresh()
const QModelIndexList& sel = ui->dataTable->selectionModel()->selectedIndexes();
QString statusMessage;
if (sel.count() > 1) {
int rows = sel.last().row() - sel.first().row() + 1;
int rows = static_cast<int>(ui->dataTable->rowsInSelection().size());
statusMessage = tr("%n row(s)", "", rows);
int columns = sel.last().column() - sel.first().column() + 1;
int columns = static_cast<int>(ui->dataTable->colsInSelection().size());
statusMessage += tr(", %n column(s)", "", columns);
if (sel.count() < Settings::getValue("databrowser", "complete_threshold").toInt()) {
@@ -1075,8 +1075,6 @@ void TableBrowser::updateInsertDeleteRecordButton()
{
// Update the delete record button to reflect number of selected records
// NOTE: We're assuming here that the selection is always contiguous, i.e. that there are never two selected
// rows with a non-selected row in between.
int rows = 0;
// If there is no model yet (because e.g. no database file is opened) there is no selection model either. So we need to check for that here
@@ -1303,22 +1301,29 @@ void TableBrowser::deleteRecord()
if(ui->dataTable->selectionModel()->selectedIndexes().isEmpty())
return;
int old_row = ui->dataTable->currentIndex().row();
while(ui->dataTable->selectionModel()->hasSelection())
{
int first_selected_row = ui->dataTable->selectionModel()->selectedIndexes().first().row();
int last_selected_row = ui->dataTable->selectionModel()->selectedIndexes().last().row();
int selected_rows_count = last_selected_row - first_selected_row + 1;
if(!m_model->removeRows(first_selected_row, selected_rows_count))
std::set<size_t> row_set = ui->dataTable->rowsInSelection();
int first_selected_row = static_cast<int>(*row_set.begin());
int rows_to_remove = 0;
int previous_row = first_selected_row - 1;
// Non-contiguous selection: remove only the contiguous
// rows in the selection in each cycle until the entire
// selection has been removed.
for(size_t row : row_set) {
if(previous_row == static_cast<int>(row - 1))
rows_to_remove++;
else
break;
}
if(!m_model->removeRows(first_selected_row, rows_to_remove))
{
QMessageBox::warning(this, QApplication::applicationName(), tr("Error deleting record:\n%1").arg(db->lastError()));
break;
}
}
if(old_row > m_model->rowCount())
old_row = m_model->rowCount();
selectTableLine(old_row);
} else {
QMessageBox::information( this, QApplication::applicationName(), tr("Please select a record first"));
}

View File

@@ -192,7 +192,7 @@
<enum>Qt::CopyAction</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ContiguousSelection</enum>
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
</widget>
</item>