Add Toolbar customization - #24309
Conversation
Doesn't work for me. That's the only problem i can find. Well there's also the case when you have added separators between the queue buttons and you disable the queueing system. It leaves some sequential separators (one after another). It wouldn't be a problem except that it doesn't seem possible to do that manually. |
|
That's a major problem, basically that's the core feature here. I'll investigate. What OS you tested on? P.S. I figured the culprit. I worked that on two parts, the reorder part was the latest and accidentally pushed only the first stage. Now should be good. |
|
One last detail from me. |
|
Before the last commit: After the last commit: |
|
Try it now. Now movement feels even more natural, I think. |
2026-05-25.19-20-19.mp42026-05-25.19-26-52.mp4 |
…menu placement, separator
Thanks for flagging this. I wasn't aware those buttons were already controlled by the queuing setting (never used them) — I only knew them as actions in the code, which is why they ended up in the Show/Hide submenu without special handling. This is now addressed: Queue actions are always present in the Show/Hide submenu at the bottom of the list, separated from the regular buttons. When torrent queuing is disabled, they appear grayed out with no checkbox — indicating they are controlled by the queuing setting, not by toolbar customization. When queuing is enabled, they appear at the end of the toolbar as a group with a separator before them (added dynamically, only if one isn't already there), and can be individually shown or hidden via the submenu. Reset to Default also respects the queuing state. Check if you like it that way. |
|
Which buttons we're talking about? Can you show me?
So, you prefer them to be dynamically shown, depending on where they were The current behavior repositions them to the end every time queuing is re-enabled. The desired behavior would be:
Would that work for you? |
How about the case when you put them in some place, hide, reorder/hide/show other buttons and then show them again? What is their position supposed to be? |
|
Their position in the action list is preserved while hidden — showing them again makes them visible exactly where they are in the list, regardless of what happened around them. For example: user moves queue buttons between Start and Stop, then hides them, then reorders other buttons. When shown again, queue buttons reappear between whatever actions now occupy those neighbouring slots — not necessarily Start and Stop anymore if those moved, but at the same index in the list. Same behavior as any other hidden button in the system; hiding never moves them, showing never repositions them. |
|
@FTA7700
|
Position is stored as an absolute slot index at the moment of hiding. When the button is shown again, it returns to that same slot number — whatever buttons now occupy the neighbouring slots. The alternative would be saving position relative to a named neighbour, but that breaks differently — if the named neighbour was also moved or hidden, the insertion point is equally arbitrary. There is no universally correct behavior when the user reshuffles things while a button is hidden. The absolute index approach is simple and predictable. Happy to change the strategy if you have a preference.
Fixed — hiding a toolbar button now uses |
6699943 to
0558715
Compare
|
Fixed some small discrepancy and also updated the description. |
stalkerok
left a comment
There was a problem hiding this comment.
I think the current result is the best one.
| } | ||
|
|
||
| void MainWindow::toolbarMenuRequested() | ||
| void MainWindow::toolbarMenuRequested(const QPoint &pos) |
| { | ||
| m_dragJustFinished = false; | ||
|
|
||
| auto *me = static_cast<QMouseEvent *>(event); |
There was a problem hiding this comment.
Could you not use such shorthands?
| auto *me = static_cast<QMouseEvent *>(event); | |
| auto *mouseEvent = static_cast<QMouseEvent *>(event); |
|
Applied all suggested changes:
Also replied on the |
| if ((action == m_spacerAction) || (action == m_columnFilterAction) | ||
| || (action->objectName() == u"actionLock"_s)) | ||
| break; |
There was a problem hiding this comment.
Broken Coding style.
| if ((action == m_spacerAction) || (action == m_columnFilterAction) | |
| || (action->objectName() == u"actionLock"_s)) | |
| break; | |
| if ((action == m_spacerAction) || (action == m_columnFilterAction) | |
| || (action->objectName() == u"actionLock"_s)) | |
| { | |
| break; | |
| } | |
| if (a->isSeparator() || a->text().isEmpty() | ||
| || (a == m_spacerAction) || (a == m_columnFilterAction) | ||
| || (a->objectName() == u"actionLock"_s)) | ||
| continue; | ||
| m_allToolbarActions.append(a); |
There was a problem hiding this comment.
Broken coding style.
| if (a->isSeparator() || a->text().isEmpty() | |
| || (a == m_spacerAction) || (a == m_columnFilterAction) | |
| || (a->objectName() == u"actionLock"_s)) | |
| continue; | |
| m_allToolbarActions.append(a); | |
| if (a->isSeparator() || a->text().isEmpty() | |
| || (a == m_spacerAction) || (a == m_columnFilterAction) | |
| || (a->objectName() == u"actionLock"_s)) | |
| { | |
| continue; | |
| } | |
| m_allToolbarActions.append(a); |
| if ((a == m_spacerAction) || (a == m_columnFilterAction) | ||
| || (a->objectName() == u"actionLock"_s)) | ||
| break; | ||
| m_ui->toolBar->removeAction(a); |
There was a problem hiding this comment.
Broken coding style.
| if ((a == m_spacerAction) || (a == m_columnFilterAction) | |
| || (a->objectName() == u"actionLock"_s)) | |
| break; | |
| m_ui->toolBar->removeAction(a); | |
| if ((a == m_spacerAction) || (a == m_columnFilterAction) | |
| || (a->objectName() == u"actionLock"_s)) | |
| { | |
| break; | |
| } | |
| m_ui->toolBar->removeAction(a); |
…ert redundant include dir
|
Pushed fixes for all of today's feedback:
|
glassez
left a comment
There was a problem hiding this comment.
Yet another review step from me.
As I can see many code in mainwindow.cpp should be encapsulated in CustomizableToolBar class.
| log/logfiltermodel.h | ||
| log/loglistview.h | ||
| log/logmodel.h | ||
| customizabletoolbar.h |
| log/logfiltermodel.cpp | ||
| log/loglistview.cpp | ||
| log/logmodel.cpp | ||
| customizabletoolbar.cpp |
|
|
||
| // Load Window state and sizes | ||
| // Snapshot all customizable toolbar actions before loadSettings may reorder them | ||
| for (QAction *a : asConst(m_ui->toolBar->actions())) |
There was a problem hiding this comment.
Could you not use such shorthands? (I think I've already asked for this.)
| for (QAction *a : asConst(m_ui->toolBar->actions())) | |
| for (QAction *action : asConst(m_ui->toolBar->actions())) |
| a->setVisible(false); | ||
| } | ||
| m_queueActionsShown = false; | ||
| loadPreferences(); |
|
|
||
| // Find nearest visible action/separator after this one | ||
| int nextIdx = idx + 1; | ||
| while (nextIdx < acts.size() && !acts[nextIdx]->isSeparator() && !acts[nextIdx]->isVisible()) |
There was a problem hiding this comment.
| while (nextIdx < acts.size() && !acts[nextIdx]->isSeparator() && !acts[nextIdx]->isVisible()) | |
| while ((nextIdx < acts.size()) && !acts[nextIdx]->isSeparator() && !acts[nextIdx]->isVisible()) |
| if (queueActionNames.contains(a->objectName())) | ||
| continue; |
There was a problem hiding this comment.
| if (queueActionNames.contains(a->objectName())) | |
| continue; | |
| if (queueActionNames.contains(a->objectName())) | |
| continue; | |
| if (!queueActionNames.contains(a->objectName())) | ||
| continue; |
There was a problem hiding this comment.
| if (!queueActionNames.contains(a->objectName())) | |
| continue; | |
| if (!queueActionNames.contains(a->objectName())) | |
| continue; | |
| if (!a->isSeparator() && !a->objectName().isEmpty()) | ||
| actionMap[a->objectName()] = a; | ||
| } | ||
| const QList<QAction *> currentActions = m_ui->toolBar->actions(); |
There was a problem hiding this comment.
| const QList<QAction *> currentActions = m_ui->toolBar->actions(); | |
| const QList<QAction *> currentActions = m_ui->toolBar->actions(); |
| if (a->isSeparator() || actionMap.contains(a->objectName())) | ||
| m_ui->toolBar->removeAction(a); | ||
| } | ||
| for (const QString &entry : savedState) |
There was a problem hiding this comment.
| for (const QString &entry : savedState) | |
| for (const QString &entry : savedState) |
| const QStringList parts = entry.split(u":"_s); | ||
| if (parts.size() != 2) | ||
| continue; | ||
| const QString &name = parts[0]; |
There was a problem hiding this comment.
| const QString &name = parts[0]; | |
| const QString &name = parts[0]; |
- Fix CMakeLists.txt alphabetical ordering for customizabletoolbar.h/.cpp - Rename shorthand variables (a, w, bw) to descriptive names across mainwindow.cpp and customizabletoolbar.cpp - Use brace-init for QMenu construction per coding guidelines - Add parentheses around while-loop conditions - Add blank-line spacing per suggestions - Move m_spacerAction next to other toolbar-related member variables
The queue-sync logic already existed in
Went through every toolbar-related function in One clear exception: One open question: should Does the overall scope match what you had in mind, or is there a narrower/different set of functions you were thinking of? |
I don't have enough time to analyze it in detail right now. I'll come back to this later. But for now, I can give you some general advice that might help you figure it out for yourself. |
What submenu do you talk about? |
Sorry, bad wording, I meant the queue section inside "Show/Hide Buttons." |
But should it really depend on business logic? Toolbar has to deal with its current actions. The caller adds "queue" actions to the toolbar if "queueing" is enabled, and deletes (does not add) them if "queueing" is disabled. Toolbar should not know what kind of actions it manages. |
|
Pushed moving toolbar bookkeeping (action registry, separators, reset-to-default) into On the queueing question - right now when queueing is off, the queue buttons still show up in the Show/Hide menu, grayed out - it's a hint that the feature state is "off". If the toolbar stops knowing about queueing and just reflects whatever actions are actually added to it, that hint goes away: queue buttons would simply not appear in the menu while queueing is off, and appear normally once it's turned on. The present state feels more natural for me as a user. But your call. Edit: Actually, we can probably keep the grayed-out hint without The |
|
I still believe the CustomizableToolBar can (should) handle it in an abstract way. Caller is free to enable/disable any of actions it added to toolbar. The toolbar shows appropriate menu items as enabled/disabled and doesn't show the items for disabled actions in the toolbar itself (if we were talking about a library class, this behavior could be made configurable, for example, by providing |
|
Ok, here are the latest updates:
|



Introduction
This PR adds full toolbar customization to qBittorrent. Users can now show/hide individual buttons, rearrange toolbar buttons by dragging, add/remove separators, and lock the toolbar to prevent accidental changes. All customization state is persisted immediately on every change and restored on startup.
This addresses long-standing feature requests - #263 #1145 #5783 #16234 #19289 #20555 #22900 and many more, for that or similar functions concerning custom layouts in the toolbar area . Also discussed in PR #24090 and that motivated this feature PR.
Major new functionalities added (further explained later):
User-facing changes
Drag to reorder (when toolbar is unlocked)
Click and hold any toolbar button, then drag it left or right. Other buttons shuffle aside one at a time as the dragged button crosses their centre — the same interaction model used by browser tab bars and mobile home screens. Release to drop the button in its new position.
Show/Hide buttons
Right-click any toolbar button → Show/Hide Buttons. A submenu lists all customizable buttons with checkboxes. Toggling a checkbox immediately shows or hides that button. Reset to Default restores the default button set, order, and separators.
Add/Remove separators
Right-click any toolbar button → Add separator before / Remove separator before (and the equivalent for after). The menu is context-aware: it only shows the option that applies to the current button's actual neighbours, accounting for hidden actions that may sit between visible ones.
Lock Toolbar
Right-click any toolbar button → Lock Toolbar. When locked (the default), drag reordering is disabled. The right-click menu for separators and show/hide remains available regardless of lock state. Lock state is persisted.
Locked zone (Intentionally left out of scope)
The spacer, column filter widget, and lock button are permanently fixed on the right side of the toolbar. They cannot be dragged, hidden, or have separators added around them. This is enforced both visually (the drag float cannot enter the locked zone) and structurally (they are registered via
lockAction()and excluded from all customization logic). Though, that can be changed, depending on the team and community views.Implementation
1. New class:
CustomizableToolBar(subclassesQToolBar)QToolBar's child buttons areQToolButtonwidgets that consume mouse events before the toolbar sees them. There is no signal or virtual method onQToolBarthat fires when a child button is right-clicked. The only reliable interception point isactionEvent(), which fires whenever an action is added or removed from the toolbar — this is used to install an event filter on each new button widget. A subclass is the natural home for this override, keeping the logic self-contained and out ofMainWindow.CustomizableToolBaris intentionally minimal. It installs event filters on child button widgets viaactionEvent, and maintains a list of locked actions and a locked state flag. All context menu logic remains inMainWindow::toolbarMenuRequested(), consistent with how every other context menu in the application is built.CustomizableToolBarhandles all drag logic internally.2. Context menu
Right-clicking a toolbar button shows a context-aware menu built dynamically on each invocation. Separator options scan backward and forward through the action list, skipping hidden non-separator actions, to find the actual nearest separator — this correctly handles queue actions that are hidden when queuing is disabled but remain in the action list:
Show/Hide buttons. Hiding a toolbar button uses
removeAction/insertActionon the toolbar rather thanQAction::setVisible(false). This is necessary becausesetVisible(false)affects every widget the action is attached to — toolbar, menu bar, and context menus all at once, disabling the hotkey along with it. WithremoveAction, the action disappears only from the toolbar while remaining fully functional in the menu bar.The Show/Hide submenu is built from a master list
m_allToolbarActions, populated once at startup beforeloadSettings()runs. This is necessary because hidden buttons are absent fromtoolBar->actions()and would otherwise not appear in the submenu at all.When a button is hidden, its current slot index is saved to
m_hiddenToolbarActions. Re-showing it reinserts it at that saved index. If other buttons were reordered while it was hidden, the saved index now has different neighbours — this is a known ambiguity with no universally correct solution. Saving by neighbour name is an equally arbitrary alternative. The team can decide on a preferred strategy.Separator detection skips hidden actions. When the queuing system is disabled, the queue-related actions (
actionTopQueuePos,actionIncreaseQueuePos, etc.) are hidden but remain in the action list. A naive check ofacts[idx - 1]->isSeparator()would return false even though visually the button is adjacent to a separator. The fix scans backward and forward through the action list, skipping hidden non-separator actions, to find the nearest visible separator. The remove lambdas operate on the actual separator found by the scan, not a hardcoded offset.Why
lockAction()calls are not inaddToolbarContextMenu():addToolbarContextMenu()is called early in theMainWindowconstructor, beforem_spacerActionis assigned. The spacer widget is created later in the constructor after the transfer list and filter widgets are set up. CallinglockAction(m_spacerAction)insideaddToolbarContextMenu()would register a null pointer and the spacer would never be recognized as locked. The threelockAction()calls are placed immediately afterm_spacerActionis assigned in the constructor.3. Drag implementation
Why not
QDrag:QDragpropagates drop events to any widget under the cursor. In testing, releasing a drag over the torrent list triggered unintended actions (e.g. opening Torrent Creator). The custom mouse-event approach confines all drag interaction to the toolbar.Press and threshold: On
MouseButtonPress, the action under the cursor is identified. Dragging only begins after the cursor moves more thanQApplication::startDragDistance()(4px), preventing accidental drags on normal clicks.Float label: When drag starts,
QWidget::grab()snapshots the button before it is made transparent. A frameless top-levelQLabelis created as the drag float. It hasQt::WA_TransparentForMouseEvents(so it does not steal mouse events) andQt::WA_ShowWithoutActivating(so it does not steal focus). It follows the cursor globally and is clamped to the toolbar's draggable zone.Opacity instead of hide: Rather than calling
setVisible(false)on the dragged button — which would cause Qt's toolbar layout to immediately reflow all other buttons — aQGraphicsOpacityEffectwith opacity 0.0 is applied. The button remains in the layout, holds its space, and other buttons do not move.Timer-based cursor polling: Mouse move events from
eventFilterare unreliable during drag because the float label sits above the toolbar and the cursor may leave the toolbar area entirely. AQTimerat 16ms intervals pollsQCursor::pos()andQApplication::mouseButtons(). This also catches mouse button releases that occur outside the toolbar.Live shuffle: As the float moves,
updateDrag()finds the action whose centre the float has crossed. When the target slot changes, one atomicremoveAction()/insertAction()is performed. The opacity effect is removed before the swap and reapplied to the new widget after, masking the reflow. Only the single neighbour at the crossing point moves:Boundary enforcement:
findBoundaryIndex()scansactions()for the first locked action (the spacer). The float's right edge is clamped to the spacer's left edge. The drop logic never inserts before a locked action.Drop and persistence: On release, the action is already in its correct position from the live shuffle. The opacity effect is removed, the float is destroyed, and
actionOrderChanged()is emitted. This signal is connected toMainWindow::saveToolbarState(), which persists the new order immediately.Edge separator handling: In case there are separators at the leftmost or rightmost position of the toolbar, now they are fully reachable. The drag float's clamp is extended slightly past the spacer boundary to allow the float to cross the last separator, and separator hit detection uses directional logic — the float's left edge when moving left, right edge when moving right — so separators respond immediately on contact rather than requiring full overlap.
Drag feel refinements: Two edge cases in the direction-detection logic have been corrected. When a button is dragged to the toolbar's leftmost position, the float clamps against the wall and briefly stops moving — this used to be misread as a direction change, causing a visible bounce-back before the button settled. Separately, the very first frame of any new drag previously defaulted to assuming rightward motion regardless of actual cursor direction, which made separator crossings feel one frame slower when dragging left compared to right. Both cases are now detected correctly from the first frame of the drag.
4. Persistence
Toolbar state is saved under
MainWindow/toolbarStateas a comma-separated string:saveToolbarState()callsPreferences::instance()->apply()explicitly to flush to disk immediately, sinceSettingsStoragebatches writes and would otherwise only flush on clean exit.On startup,
loadSettings()builds the action map from a snapshot taken before any removals, then removes and reinserts non-locked actions in saved order before the spacer anchor. The locked zone is never touched during this process.QMainWindow::saveState()was not used because it handles toolbar docking position and visibility as a whole unit, but does not save individual action order, individual action visibility, or separator positions.5. Queue actions (Top/Move Up/Move Down/Bottom of Queue)
Queue actions are handled as a special case within the show/hide system, because their visibility is also controlled by the torrent queuing setting in preferences.
They are always present in the Show/Hide submenu, separated from the regular buttons at the bottom of the list
When torrent queuing is disabled, they appear grayed out in the submenu with no checkbox — indicating they are controlled by the queuing setting, not by toolbar customization
When torrent queuing is enabled, they appear at the end of the toolbar as a group with a separator before them (added dynamically, only if one isn't already there), and can be individually shown or hidden via the submenu like any other button
Reset to Default hides them and resets their position; if queuing is on they are immediately restored to the end of the toolbar.
6. Reset to Default
mainwindow.ui. Existing users who have never customized their toolbar will see no difference. The lock is on by default, matching current behaviour exactly.loadPreferences()immediately restores them to the end of the toolbar as a group with a separator before them.Files changed
src/gui/customizabletoolbar.hsrc/gui/customizabletoolbar.cppsrc/gui/mainwindow.hsrc/gui/mainwindow.cppsrc/gui/mainwindow.uiCustomizableToolBar, added customwidgets declarationsrc/gui/CMakeLists.txtCMAKE_CURRENT_SOURCE_DIRto include pathsrc/base/preferences.hsrc/base/preferences.cppReady for questions and discussions!