1 /*
2 * Copyright (C) 2016 Hong Jen Yee (PCMan) <pcman.tw@gmail.com>
3 *
4 * This library is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU Lesser General Public
6 * License as published by the Free Software Foundation; either
7 * version 2.1 of the License, or (at your option) any later version.
8 *
9 * This library is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12 * Lesser General Public License for more details.
13 *
14 * You should have received a copy of the GNU Lesser General Public
15 * License along with this library; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17 *
18 */
19
20 #include "pathbar.h"
21 #include "pathbar_p.h"
22 #include <QToolButton>
23 #include <QScrollArea>
24 #include <QScrollBar>
25 #include <QHBoxLayout>
26 #include <QResizeEvent>
27 #include <QContextMenuEvent>
28 #include <QMenu>
29 #include <QClipboard>
30 #include <QApplication>
31 #include <QTimer>
32 #include <QDebug>
33 #include "pathedit.h"
34
35
36 namespace Fm {
37
PathBar(QWidget * parent)38 PathBar::PathBar(QWidget* parent):
39 QWidget(parent),
40 tempPathEdit_(nullptr),
41 toggledBtn_(nullptr) {
42
43 QHBoxLayout* topLayout = new QHBoxLayout(this);
44 topLayout->setContentsMargins(0, 0, 0, 0);
45 topLayout->setSpacing(0);
46 bool rtl(layoutDirection() == Qt::RightToLeft);
47
48 // the arrow button used to scroll to start of the path
49 scrollToStart_ = new QToolButton(this);
50 scrollToStart_->setArrowType(rtl ? Qt::RightArrow : Qt::LeftArrow);
51 scrollToStart_->setAutoRepeat(true);
52 scrollToStart_->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::MinimumExpanding);
53 connect(scrollToStart_, &QToolButton::clicked, this, &PathBar::onScrollButtonClicked);
54 topLayout->addWidget(scrollToStart_);
55
56 // there might be too many buttons when the path is long, so make it scrollable.
57 scrollArea_ = new QScrollArea(this);
58 scrollArea_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
59 scrollArea_->setFrameShape(QFrame::NoFrame);
60 scrollArea_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
61 scrollArea_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
62 scrollArea_->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents);
63 scrollArea_->verticalScrollBar()->setDisabled(true);
64 connect(scrollArea_->horizontalScrollBar(), &QAbstractSlider::valueChanged, this, &PathBar::setArrowEnabledState);
65 topLayout->addWidget(scrollArea_, 1); // stretch factor=1, make it expandable
66
67 // the arrow button used to scroll to end of the path
68 scrollToEnd_ = new QToolButton(this);
69 scrollToEnd_->setArrowType(rtl ? Qt::LeftArrow : Qt::RightArrow);
70 scrollToEnd_->setAutoRepeat(true);
71 scrollToEnd_->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::MinimumExpanding);
72 connect(scrollToEnd_, &QToolButton::clicked, this, &PathBar::onScrollButtonClicked);
73 topLayout->addWidget(scrollToEnd_);
74
75 // container widget of the path buttons
76 buttonsWidget_ = new QWidget(this);
77 buttonsWidget_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
78
79 buttonsLayout_ = new QHBoxLayout(buttonsWidget_);
80 buttonsLayout_->setContentsMargins(0, 0, 0, 0);
81 buttonsLayout_->setSpacing(0);
82 buttonsLayout_->setSizeConstraint(QLayout::SetFixedSize); // required when added to scroll area according to QScrollArea doc.
83 scrollArea_->setWidget(buttonsWidget_); // make the buttons widget scrollable if the path is too long
84 }
85
resizeEvent(QResizeEvent * event)86 void PathBar::resizeEvent(QResizeEvent* event) {
87 QWidget::resizeEvent(event);
88 if(event->oldSize().width() != event->size().width()) {
89 updateScrollButtonVisibility();
90 QTimer::singleShot(0, this, SLOT(ensureToggledVisible()));
91 }
92 }
93
wheelEvent(QWheelEvent * event)94 void PathBar::wheelEvent(QWheelEvent* event) {
95 QWidget::wheelEvent(event);
96 QAbstractSlider::SliderAction action = QAbstractSlider::SliderNoAction;
97 int vDelta = event->angleDelta().y();
98 if(vDelta > 0) {
99 if(scrollToStart_->isEnabled()) {
100 action = QAbstractSlider::SliderSingleStepSub;
101 }
102 }
103 else if(vDelta < 0) {
104 if(scrollToEnd_->isEnabled()) {
105 action = QAbstractSlider::SliderSingleStepAdd;
106 }
107 }
108 scrollArea_->horizontalScrollBar()->triggerAction(action);
109 }
110
mousePressEvent(QMouseEvent * event)111 void PathBar::mousePressEvent(QMouseEvent* event) {
112 QWidget::mousePressEvent(event);
113 if(event->button() == Qt::LeftButton) {
114 openEditor();
115 }
116 else if(event->button() == Qt::MiddleButton) {
117 PathButton* btn = qobject_cast<PathButton*>(childAt(event->x(), event->y()));
118 if(btn != nullptr) {
119 scrollArea_->ensureWidgetVisible(btn,
120 1); // a harmless compensation for a miscalculation in Qt
121 Q_EMIT middleClickChdir(pathForButton(btn));
122 }
123 }
124 }
125
contextMenuEvent(QContextMenuEvent * event)126 void PathBar::contextMenuEvent(QContextMenuEvent* event) {
127 QMenu* menu = new QMenu(this);
128 connect(menu, &QMenu::aboutToHide, menu, &QMenu::deleteLater);
129
130 QAction* action = menu->addAction(tr("&Edit Path"));
131 connect(action, &QAction::triggered, this, &PathBar::openEditor);
132
133 action = menu->addAction(tr("&Copy Path"));
134 connect(action, &QAction::triggered, this, &PathBar::copyPath);
135
136 menu->popup(mapToGlobal(event->pos()));
137 }
138
updateScrollButtonVisibility()139 void PathBar::updateScrollButtonVisibility() {
140 // Wait for the horizontal scrollbar to be completely shaped.
141 // Without this, the enabled state of arrow buttons might be
142 // wrong when the pathbar is created for the first time.
143 QTimer::singleShot(0, this, SLOT(setScrollButtonVisibility()));
144 }
145
setScrollButtonVisibility()146 void PathBar::setScrollButtonVisibility() {
147 bool showScrollers;
148 if(tempPathEdit_ != nullptr) {
149 showScrollers = false;
150 }
151 else {
152 showScrollers = (buttonsLayout_->sizeHint().width() > width());
153 }
154 scrollToStart_->setVisible(showScrollers);
155 scrollToEnd_->setVisible(showScrollers);
156 if(showScrollers) {
157 QScrollBar* sb = scrollArea_->horizontalScrollBar();
158 int value = sb->value();
159 scrollToStart_->setEnabled(value != sb->minimum());
160 scrollToEnd_->setEnabled(value != sb->maximum());
161 // align scroll buttons horizontally
162 scrollToStart_->setMaximumHeight(qMax(buttonsWidget_->height(), scrollToStart_->minimumSizeHint().height()));
163 scrollToEnd_->setMaximumHeight(qMax(buttonsWidget_->height(), scrollToEnd_->minimumSizeHint().height()));
164 }
165 }
166
pathForButton(PathButton * btn)167 Fm::FilePath PathBar::pathForButton(PathButton* btn) {
168 std::string fullPath;
169 int buttonCount = buttonsLayout_->count() - 1; // the last item is a spacer
170 for(int i = 0; i < buttonCount; ++i) {
171 if(!fullPath.empty() && fullPath.back() != '/') {
172 fullPath += '/';
173 }
174 PathButton* elem = static_cast<PathButton*>(buttonsLayout_->itemAt(i)->widget());
175 fullPath += elem->name();
176 if(elem == btn)
177 break;
178 }
179 return Fm::FilePath::fromPathStr(fullPath.c_str());
180 }
181
onButtonToggled(bool checked)182 void PathBar::onButtonToggled(bool checked) {
183 if(checked) {
184 PathButton* btn = static_cast<PathButton*>(sender());
185 toggledBtn_ = btn;
186 currentPath_ = pathForButton(btn);
187 Q_EMIT chdir(currentPath_);
188
189 // since scrolling to the toggled buton will happen correctly only when the
190 // layout is updated and because the update is disabled on creating buttons
191 // in setPath(), the update status can be used as a sign to know when to wait
192 if(updatesEnabled()) {
193 scrollArea_->ensureWidgetVisible(btn, 1);
194 }
195 else {
196 QTimer::singleShot(0, this, SLOT(ensureToggledVisible()));
197 }
198 }
199 }
200
ensureToggledVisible()201 void PathBar::ensureToggledVisible() {
202 if(toggledBtn_ != nullptr && tempPathEdit_ == nullptr) {
203 scrollArea_->ensureWidgetVisible(toggledBtn_, 1);
204 }
205 }
206
onScrollButtonClicked()207 void PathBar::onScrollButtonClicked() {
208 QToolButton* btn = static_cast<QToolButton*>(sender());
209 QAbstractSlider::SliderAction action = QAbstractSlider::SliderNoAction;
210 if(btn == scrollToEnd_) {
211 action = QAbstractSlider::SliderSingleStepAdd;
212 }
213 else if(btn == scrollToStart_) {
214 action = QAbstractSlider::SliderSingleStepSub;
215 }
216 scrollArea_->horizontalScrollBar()->triggerAction(action);
217 }
218
setPath(Fm::FilePath path)219 void PathBar::setPath(Fm::FilePath path) {
220 if(currentPath_ == path) { // same path, do nothing
221 return;
222 }
223
224 auto oldPath = std::move(currentPath_);
225 currentPath_ = std::move(path);
226 // check if we already have a button for this path
227 int buttonCount = buttonsLayout_->count() - 1; // the last item is a spacer
228 if(oldPath && currentPath_.isPrefixOf(oldPath)) {
229 for(int i = buttonCount - 1; i >= 0; --i) {
230 auto btn = static_cast<PathButton*>(buttonsLayout_->itemAt(i)->widget());
231 if(pathForButton(btn) == currentPath_) {
232 btn->setChecked(true); // toggle the button
233 /* we don't need to emit chdir signal here since later
234 * toggled signal will be triggered on the button, which
235 * in turns emit chdir. */
236 return;
237 }
238 }
239 }
240
241 /* FIXME: if the new path is the subdir of our full path, actually
242 * we can append several new buttons rather than re-create
243 * all of the buttons. This can reduce flickers. */
244
245 setUpdatesEnabled(false);
246 toggledBtn_ = nullptr;
247 // we do not have the path in the buttons list
248 // destroy existing path element buttons and the spacer
249 QLayoutItem* item;
250 while((item = buttonsLayout_->takeAt(0)) != nullptr) {
251 delete item->widget();
252 delete item;
253 }
254
255 // create new buttons for the new path
256 auto btnPath = currentPath_;
257 while(btnPath) {
258 std::string name;
259 QString displayName;
260 auto parent = btnPath.parent();
261 // FIXME: some buggy uri types, such as menu://, fail to return NULL when there is no parent path.
262 // Instead, the path itself is returned. So we check if the parent path is the same as current path.
263 auto isRoot = !parent.isValid() || parent == btnPath;
264 if(isRoot) {
265 displayName = QString::fromUtf8(btnPath.displayName().get());
266 name = btnPath.toString().get();
267 }
268 else {
269 displayName = QString::fromUtf8(btnPath.baseName().get());
270 // NOTE: "name" is used for making the path from its components in PathBar::pathForButton().
271 // In places like folders inside trashes of mounted volumes, FilePath::baseName() cannot be
272 // used for making a full path. On the other hand, the base name of FilePath::displayName()
273 // causes trouble when a file name contains newline or tab.
274 //
275 // Therefore, we simply set "name" to the last component of FilePath::toString().
276 auto pathStr = QString::fromUtf8(btnPath.toString().get());
277 pathStr = pathStr.section(QLatin1Char('/'), -1);
278 name = pathStr.toStdString();
279 }
280 // double ampersands to distinguish them from mnemonics
281 displayName.replace(QLatin1Char('&'), QLatin1String("&&"));
282 auto btn = new PathButton(name, displayName, isRoot, buttonsWidget_);
283 btn->show();
284 connect(btn, &QAbstractButton::toggled, this, &PathBar::onButtonToggled);
285 buttonsLayout_->insertWidget(0, btn);
286 if(isRoot) { // this is the root element of the path
287 break;
288 }
289 btnPath = parent;
290 }
291 buttonsLayout_->addStretch(1); // add a spacer at the tail of the buttons
292
293 // we don't want to scroll vertically. make the scroll area fit the height of the buttons
294 // FIXME: this is a little bit hackish :-(
295 scrollArea_->setFixedHeight(buttonsLayout_->sizeHint().height());
296 updateScrollButtonVisibility();
297
298 // to guarantee that the button will be scrolled to correctly,
299 // it should be toggled only after the layout update starts above
300 buttonCount = buttonsLayout_->count() - 1;
301 if(buttonCount > 0) {
302 PathButton* lastBtn = static_cast<PathButton*>(buttonsLayout_->itemAt(buttonCount - 1)->widget());
303 // we don't have to emit the chdir signal since the "onButtonToggled()" slot will be triggered by this.
304 lastBtn->setChecked(true);
305 }
306
307 setUpdatesEnabled(true);
308 }
309
openEditor()310 void PathBar::openEditor() {
311 if(tempPathEdit_ == nullptr) {
312 tempPathEdit_ = new PathEdit(this);
313 delete layout()->replaceWidget(scrollArea_, tempPathEdit_, Qt::FindDirectChildrenOnly);
314 scrollArea_->hide();
315 scrollToStart_->setVisible(false);
316 scrollToEnd_->setVisible(false);
317 tempPathEdit_->setText(QString::fromUtf8(currentPath_.toString().get()));
318
319 connect(tempPathEdit_, &PathEdit::returnPressed, this, &PathBar::onReturnPressed);
320 connect(tempPathEdit_, &PathEdit::editingFinished, this, &PathBar::closeEditor);
321 }
322 tempPathEdit_->selectAll();
323 QApplication::clipboard()->setText(tempPathEdit_->text(), QClipboard::Selection);
324 QTimer::singleShot(0, tempPathEdit_, SLOT(setFocus()));
325 }
326
closeEditor()327 void PathBar::closeEditor() {
328 if(tempPathEdit_ == nullptr) {
329 return;
330 }
331 // If a menu has popped up synchronously (with QMenu::exec), the path buttons may be drawn
332 // but the path-edit may not disappear until the menu is closed. So, we hide it here.
333 // However, since hiding the path-edit makes it lose focus and emit editingFinished(),
334 // we should first disconnect from it to avoid recursive calling of the current function.
335 tempPathEdit_->disconnect();
336 tempPathEdit_->setVisible(false);
337 delete layout()->replaceWidget(tempPathEdit_, scrollArea_, Qt::FindDirectChildrenOnly);
338 scrollArea_->show();
339 if(buttonsLayout_->sizeHint().width() > width()) {
340 scrollToStart_->setVisible(true);
341 scrollToEnd_->setVisible(true);
342 }
343
344 tempPathEdit_->deleteLater();
345 tempPathEdit_ = nullptr;
346 updateScrollButtonVisibility();
347
348 Q_EMIT editingFinished();
349 }
350
copyPath()351 void PathBar::copyPath() {
352 QApplication::clipboard()->setText(QString::fromUtf8(currentPath_.toString().get()));
353 }
354
onReturnPressed()355 void PathBar::onReturnPressed() {
356 QByteArray pathStr = tempPathEdit_->text().toLocal8Bit();
357 setPath(Fm::FilePath::fromPathStr(pathStr.constData()));
358 }
359
setArrowEnabledState(int value)360 void PathBar::setArrowEnabledState(int value) {
361 if(buttonsLayout_->sizeHint().width() > width()) {
362 QScrollBar* sb = scrollArea_->horizontalScrollBar();
363 scrollToStart_->setEnabled(value != sb->minimum());
364 scrollToEnd_->setEnabled(value != sb->maximum());
365 }
366 }
367
368 } // namespace Fm
369