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