Compare commits

...

38 Commits

Author SHA1 Message Date
David Capello 5e12a5d3d8 Restore the "Position" section in Timeline configuration
We're going to keep this section for a couple of versions as old users
get used to the new Workspace Layout customization.

The "Position" section will contain the layout icon so we can switch
the Workspace Layout combobox from there too,
2025-08-04 19:41:59 -03:00
David Capello 60112cd929 Close layout combobox after clicking the reset/delete layout button
Without this, the combobox is still visible and clicking the button
again will crash the program.
2025-08-04 19:41:59 -03:00
David Capello a1fb751323 Remove base field from "New Layout" dialog 2025-08-04 19:41:59 -03:00
David Capello 24046600ec Fix loading modified default layouts at the very beginning 2025-08-04 19:41:59 -03:00
David Capello 34aae80eab Fix some strings
From https://github.com/aseprite/aseprite/pull/3485#pullrequestreview-2824195211
2025-08-04 19:41:59 -03:00
David Capello c8ae1017b3 Fix crashes resetting the default layout + remove some hacks
With this patch we've also simplified some hacks handling the
populateComboBox() call, deferring the deletion of widgets/items when
we are inside an event generated by those items.
2025-08-04 19:41:59 -03:00
David Capello 9c5d1feaf5 Fix several layout saving/loading issues
* Better support to load legacy timeline information: we have to
  estimate workspace bounds)
* Auto-save layouts after resizing a splitter/dock
* Fix resetting expansive widgets inside docks after switching tabs
* Load mirrored layout correctly if it was the last selected layout
2025-08-04 19:41:59 -03:00
David Capello aa66d260ad Fix switch/break style 2025-08-04 19:41:59 -03:00
David Capello 86c7fae42c Move LayoutSelector::setActiveLayoutId() impl to .cpp file 2025-08-04 19:41:59 -03:00
David Capello 778e62a411 Minor changes (format, remove unused/unnecessary vars/keywords) 2025-08-04 19:41:59 -03:00
David Capello b08662eeca Change notification flag location inside the layout selector widget
This improves the look of the flag as another icon above the tabs
bottom and at the right of the layout selector icon.
2025-08-04 19:41:59 -03:00
David Capello d6c5ac6786 [theme] New layout selector icon + bg color fixes 2025-08-04 19:41:59 -03:00
David Capello e0226d95d9 Improve layout selector widget location when menu bar is visible/hidden 2025-08-04 19:41:59 -03:00
David Capello 7eb2df6965 Remove unused member variable from ui::Dock 2025-08-04 19:41:59 -03:00
Christian Kaiser c445075a79 Polish layout handling
@dacap's notes: A description of the included changes:

* Improve UX auto-saving layouts when docks are modified, and new 'X'
  icon to delete layouts (or reset the 'Default' layout).
* Remove old timeline position controls (Left/Right/Bottom buttons)
  from the Timeline configuration and from the layout selector
* Add support to drag and drop docks to other sides with real-time
  feedback using a semi-transparent UILayer
* Add a context menu w/right-click to dock the widget at the supported
  sides without drag-and-drop

Some review comments in https://github.com/dacap/aseprite/pull/2
2025-08-04 19:41:59 -03:00
David Capello f556052fc6 Show handles in dockable areas to drag-and-drop them 2025-08-04 19:41:58 -03:00
David Capello c8e9b33ad3 Fix bug where the ToolBar popup wasn't being opened
This was a new issue with combination of
9b90159d1b, probably we should close the
popup when the window is resized (instead of onSizeHint()).
2025-08-04 19:41:58 -03:00
David Capello 37d2be7668 Update saving/loading dock layouts w/tinyxml2 library 2025-08-04 19:41:58 -03:00
David Capello 43140e71ec Save layout changes immediately when docks are resized
This patch includes:

* The layout is updated immediately when docks are resized (before
  this the layout was like a snapshot of the configuration when the
  layout was created or overwritten with the "New Layout" option)
* Saving the active layout used in
  preferences.general.workspace_layout so we can restore it after
  restarting Aseprite
* Change "UI Layout" to "Workspace Layout"
* Some strings moved to en.ini file for i18n
* Fixed a crash on MainWindow::onActiveViewChange() when the
  application was being closed
2025-08-04 19:41:58 -03:00
David Capello eecc14153d Improve borders of context & color bars for both sides (left/right) 2025-08-04 19:41:58 -03:00
David Capello 131aa8b3df Add View > Workspace Layout option to switch the layout w/keys 2025-08-04 19:41:58 -03:00
David Capello ab69421096 Add possibility to overwrite existent layouts 2025-08-04 19:41:58 -03:00
David Capello 0e4e776bc0 Include StatusBar in the set of customizable widgets in the layout 2025-08-04 19:41:58 -03:00
David Capello 78b9f340f7 Save/Load user defined layouts in new user.aseprite-layouts file
And now we store the TiXmlElement for each Layout, instead of
converting from/to text back and forth.
2025-08-04 19:41:58 -03:00
David Capello 92a039a619 Add option to save/restore user-defined layouts on memory
This happens only in memory at the moment (layouts are not saved in
disk yet), and the customization is quite simple (only size of
splitters, timeline position). But in the future we should be able to
dock elements in any place.
2025-08-04 19:41:58 -03:00
David Capello dc040e81e7 Set the initial timeline position in the LayoutSelector correctly 2025-08-04 19:41:58 -03:00
David Capello 465574e2db Show the timeline when we set its position from the LayoutSelector 2025-08-04 19:41:58 -03:00
David Capello 544f711adc Improve the layout selector UI
Changes:
* Now we use the "user data" icon as the button to expand the layouts
  combobox
* Added a tooltip to this icon
* Added buttons to configure the Timeline position in the same
  combobox
* Fixed some bugs in Dock using space for hidden widgets
2025-08-04 19:41:58 -03:00
David Capello ec95323856 Save/restore color bar splitter position correctly 2025-08-04 19:41:58 -03:00
David Capello 9b56d5ba3d Save/restore timeline splitter position correctly 2025-08-04 19:41:58 -03:00
David Capello 2c0920f514 Fix popups & tooltips direction when ToolBar is docked at the left side 2025-08-04 19:41:58 -03:00
David Capello ba39b56096 Fix std::clamp() max bound in TipWindow::pointAt() 2025-08-04 19:41:58 -03:00
David Capello ad4d00ced2 Use std::unique_ptr in ToolBar members 2025-08-04 19:41:58 -03:00
David Capello 6df6037fdc Fix memory leaks in MainWindow
Temporary/created subdocks must be deleted automatically, and children
that are not part of the window hierarchy must be deleted explicitly
now (using some std::unique_ptrs).
2025-08-04 19:41:58 -03:00
David Capello bb65296a1a Add Dock widget, initial & basic version of dockable elements (#518)
Some missing features so far:

1) Restore old layout configuration (color bar split pos, timeline
   pos, etc.) and migrate to new Dock layout
2) Load/saving Dock layout
3) Create & customize current layoout (drag-and-drop widgets, etc.)
2025-08-04 19:41:58 -03:00
Christian Kaiser eaa2bdf0af [lua] Process mnemonics consistently for plugins (fix #5250) 2025-08-04 15:58:29 -03:00
Christian Kaiser 57309e5aa5 Allow gif encoding to be stopped (fix #2619) 2025-08-01 20:46:32 -03:00
Christian Kaiser 4bb9239f50 [lua] Add `resizeable` property to Dialog constructor (fix #5177)
build / build (Debug, macos-latest, lua, cli) (push) Has been cancelled Details
build / build (Debug, macos-latest, noscripts, cli) (push) Has been cancelled Details
build / build (Debug, ubuntu-latest, lua, cli) (push) Has been cancelled Details
build / build (Debug, ubuntu-latest, noscripts, cli) (push) Has been cancelled Details
build / build (Debug, windows-latest, lua, cli) (push) Has been cancelled Details
build / build (Debug, windows-latest, noscripts, cli) (push) Has been cancelled Details
build / build (RelWithDebInfo, macos-latest, lua, gui) (push) Has been cancelled Details
build / build (RelWithDebInfo, ubuntu-latest, lua, gui) (push) Has been cancelled Details
build / build (RelWithDebInfo, windows-latest, lua, gui) (push) Has been cancelled Details
2025-08-01 18:57:50 -03:00
47 changed files with 2791 additions and 264 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -216,7 +216,7 @@
<part id="tab_normal" x="2" y="112" w1="4" w2="5" w3="5" h1="4" h2="6" h3="2" /> <part id="tab_normal" x="2" y="112" w1="4" w2="5" w3="5" h1="4" h2="6" h3="2" />
<part id="tab_active" x="16" y="112" w1="4" w2="7" w3="5" h1="4" h2="6" h3="2" /> <part id="tab_active" x="16" y="112" w1="4" w2="7" w3="5" h1="4" h2="6" h3="2" />
<part id="tab_bottom_active" x="16" y="124" w1="4" w2="7" w3="5" h1="2" h2="1" h3="2" /> <part id="tab_bottom_active" x="16" y="124" w1="4" w2="7" w3="5" h1="2" h2="1" h3="2" />
<part id="tab_bottom_normal" x="2" y="124" w="12" h="5" /> <part id="tab_bottom_normal" x="2" y="124" w1="4" w2="5" w3="3" h1="2" h2="1" h3="2" />
<part id="tab_filler" x="0" y="112" w="2" h="12" /> <part id="tab_filler" x="0" y="112" w="2" h="12" />
<part id="tab_modified_icon_normal" x="32" y="112" w="5" h="5" /> <part id="tab_modified_icon_normal" x="32" y="112" w="5" h="5" />
<part id="tab_modified_icon_active" x="32" y="117" w="5" h="5" /> <part id="tab_modified_icon_active" x="32" y="117" w="5" h="5" />
@ -374,6 +374,7 @@
<part id="icon_close" x="152" y="256" w="7" h="7" /> <part id="icon_close" x="152" y="256" w="7" h="7" />
<part id="icon_search" x="160" y="256" w="8" h="8" /> <part id="icon_search" x="160" y="256" w="8" h="8" />
<part id="icon_user_data" x="168" y="256" w="8" h="8" /> <part id="icon_user_data" x="168" y="256" w="8" h="8" />
<part id="icon_layout" x="176" y="256" w="5" h="6" />
<part id="icon_pos" x="144" y="264" w="8" h="8" /> <part id="icon_pos" x="144" y="264" w="8" h="8" />
<part id="icon_size" x="152" y="264" w="8" h="8" /> <part id="icon_size" x="152" y="264" w="8" h="8" />
<part id="icon_selsize" x="160" y="264" w="8" h="8" /> <part id="icon_selsize" x="160" y="264" w="8" h="8" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -212,7 +212,7 @@
<part id="tab_normal" x="2" y="112" w1="4" w2="5" w3="5" h1="4" h2="6" h3="2" /> <part id="tab_normal" x="2" y="112" w1="4" w2="5" w3="5" h1="4" h2="6" h3="2" />
<part id="tab_active" x="16" y="112" w1="4" w2="7" w3="5" h1="4" h2="6" h3="2" /> <part id="tab_active" x="16" y="112" w1="4" w2="7" w3="5" h1="4" h2="6" h3="2" />
<part id="tab_bottom_active" x="16" y="124" w1="4" w2="7" w3="5" h1="2" h2="1" h3="2" /> <part id="tab_bottom_active" x="16" y="124" w1="4" w2="7" w3="5" h1="2" h2="1" h3="2" />
<part id="tab_bottom_normal" x="2" y="124" w="12" h="5" /> <part id="tab_bottom_normal" x="2" y="124" w1="4" w2="5" w3="3" h1="2" h2="1" h3="2" />
<part id="tab_filler" x="0" y="112" w="2" h="12" /> <part id="tab_filler" x="0" y="112" w="2" h="12" />
<part id="tab_modified_icon_normal" x="32" y="112" w="5" h="5" /> <part id="tab_modified_icon_normal" x="32" y="112" w="5" h="5" />
<part id="tab_modified_icon_active" x="32" y="117" w="5" h="5" /> <part id="tab_modified_icon_active" x="32" y="117" w="5" h="5" />
@ -370,6 +370,7 @@
<part id="icon_close" x="152" y="256" w="7" h="7" /> <part id="icon_close" x="152" y="256" w="7" h="7" />
<part id="icon_search" x="160" y="256" w="8" h="8" /> <part id="icon_search" x="160" y="256" w="8" h="8" />
<part id="icon_user_data" x="168" y="256" w="8" h="8" /> <part id="icon_user_data" x="168" y="256" w="8" h="8" />
<part id="icon_layout" x="176" y="256" w="5" h="6" />
<part id="icon_pos" x="144" y="264" w="8" h="8" /> <part id="icon_pos" x="144" y="264" w="8" h="8" />
<part id="icon_size" x="152" y="264" w="8" h="8" /> <part id="icon_size" x="152" y="264" w="8" h="8" />
<part id="icon_selsize" x="160" y="264" w="8" h="8" /> <part id="icon_selsize" x="160" y="264" w="8" h="8" />

View File

@ -175,6 +175,7 @@
<param name="popup" value="background" /> <param name="popup" value="background" />
</key> </key>
<key command="ShowExtras" shortcut="Ctrl+H" /> <key command="ShowExtras" shortcut="Ctrl+H" />
<key command="ToggleWorkspaceLayout" shortcut="Shift+W" />
<!-- Tabs --> <!-- Tabs -->
<key command="GotoNextTab" shortcut="Ctrl+Tab" /> <key command="GotoNextTab" shortcut="Ctrl+Tab" />
<key command="GotoPreviousTab" shortcut="Ctrl+Shift+Tab" /> <key command="GotoPreviousTab" shortcut="Ctrl+Shift+Tab" />
@ -1002,6 +1003,7 @@
</menu> </menu>
<menu text="@.view" id="view_menu"> <menu text="@.view" id="view_menu">
<item command="DuplicateView" text="@.view_duplicate_view" group="view_new" /> <item command="DuplicateView" text="@.view_duplicate_view" group="view_new" />
<item command="ToggleWorkspaceLayout" text="@.view_workspace_layout" />
<separator /> <separator />
<item command="ShowExtras" text="@.view_show_extras" /> <item command="ShowExtras" text="@.view_show_extras" />
<menu text="@.view_show" group="view_extras"> <menu text="@.view_show" group="view_extras">

View File

@ -167,6 +167,7 @@
<option id="keep_closed_sprite_on_memory_for" type="double" default="15.0" /> <option id="keep_closed_sprite_on_memory_for" type="double" default="15.0" />
<option id="show_full_path" type="bool" default="true" /> <option id="show_full_path" type="bool" default="true" />
<option id="edit_full_path" type="bool" default="false" /> <option id="edit_full_path" type="bool" default="false" />
<option id="workspace_layout" type="std::string" />
<option id="timeline_position" type="TimelinePosition" default="TimelinePosition::BOTTOM" /> <option id="timeline_position" type="TimelinePosition" default="TimelinePosition::BOTTOM" />
<option id="timeline_layer_panel_width" type="int" default="100" /> <option id="timeline_layer_panel_width" type="int" default="100" />
<option id="show_menu_bar" type="bool" default="true" /> <option id="show_menu_bar" type="bool" default="true" />

View File

@ -488,6 +488,7 @@ TilesetDuplicate = Duplicate Tileset
Undo = Undo Undo = Undo
UndoHistory = Undo History UndoHistory = Undo History
UnlinkCel = Unlink Cel UnlinkCel = Unlink Cel
ToggleWorkspaceLayout = Toggle Workspace Layout
Zoom = Zoom Zoom = Zoom
Zoom_In = Zoom In Zoom_In = Zoom In
Zoom_Out = Zoom Out Zoom_Out = Zoom Out
@ -1164,6 +1165,7 @@ select_load_from_file = &Load from MSK file
select_save_to_file = &Save to MSK file select_save_to_file = &Save to MSK file
view = &View view = &View
view_duplicate_view = Duplicate &View view_duplicate_view = Duplicate &View
view_workspace_layout = Workspace &Layout
view_show_extras = &Extras view_show_extras = &Extras
view_show = &Show view_show = &Show
view_show_layer_edges = &Layer Edges view_show_layer_edges = &Layer Edges
@ -1205,6 +1207,14 @@ help_twitter = Twitter
help_enter_license = Enter &License help_enter_license = Enter &License
help_about = &About help_about = &About
[main_window]
layout = Workspace Layout
default_layout = Default
mirrored_default_layout = Mirrored Default
timeline = Timeline
user_layouts = User Layouts
new_layout = New Layout...
[mask_by_color] [mask_by_color]
title = Select Color title = Select Color
label_color = Color: label_color = Color:
@ -1233,6 +1243,20 @@ name = Name:
tileset = Tileset: tileset = Tileset:
default_new_layer_name = New Layer default_new_layer_name = New Layer
[new_layout]
title = New Workspace Layout
name = Name:
deleting_layout = Deleting Layout
deleting_layout_confirmation = Are you sure you want to delete the '{}' layout?
restoring_layout = Restoring Layout
restoring_layout_confirmation = Are you sure you want to restore the '{}' layout?
[dock]
left = Dock Left
right = Dock Right
top = Dock Top
bottom = Dock Bottom
[news_listbox] [news_listbox]
more = More... more = More...
problem_loading = Problems loading news. Please retry. problem_loading = Problems loading news. Please retry.
@ -1849,9 +1873,6 @@ more_options = More Options
[timeline_conf] [timeline_conf]
position = Position: position = Position:
left = &Left
right = &Right
bottom = &Bottom
frame_header = Frame Header: frame_header = Frame Header:
first_frame = First Frame: first_frame = First Frame:
thumbnails = Thumbnails thumbnails = Thumbnails

View File

@ -1,30 +0,0 @@
<!-- Aseprite -->
<!-- Copyright (C) 2001-2017 by David Capello -->
<gui>
<window id="main_window" noborders="true" desktop="true">
<vbox noborders="true" expansive="true">
<hbox noborders="true" id="menu_bar_placeholder" />
<hbox noborders="true" id="tabs_placeholder" />
<splitter id="color_bar_splitter"
horizontal="true" expansive="true"
by="pixel"
style="workspace_splitter">
<vbox noborders="true" id="color_bar_placeholder" />
<vbox noborders="true" expansive="true">
<vbox noborders="true" id="context_bar_placeholder" />
<hbox noborders="true" expansive="true">
<splitter id="timeline_splitter"
vertical="true" expansive="true"
by="percetage" position="100"
style="workspace_splitter">
<hbox noborders="true" id="workspace_placeholder" expansive="true" />
<vbox noborders="true" id="timeline_placeholder" expansive="true" />
</splitter>
<vbox noborders="true" id="tool_bar_placeholder" />
</hbox>
</vbox>
</splitter>
<hbox noborders="true" id="status_bar_placeholder" />
</vbox>
</window>
</gui>

View File

@ -0,0 +1,19 @@
<!-- Aseprite -->
<!-- Copyright (C) 2025 by Igara Studio S.A. -->
<gui>
<window id="new_layout" text="@.title">
<vbox>
<hbox>
<label text="@.name" />
<entry maxsize="128" id="name" magnet="true" expansive="true" />
</hbox>
<separator horizontal="true" />
<hbox homogeneous="true" cell_align="right">
<button text="@general.ok" closewindow="true" id="ok" disabled="true" minwidth="60" magnet="true" />
<button text="@general.cancel" closewindow="true" />
</hbox>
</vbox>
</window>
</gui>

View File

@ -6,13 +6,7 @@
<hbox> <hbox>
<vbox> <vbox>
<separator cell_hspan="2" text="@.position" left="true" horizontal="true" /> <separator cell_hspan="2" text="@.position" left="true" horizontal="true" />
<hbox> <button id="layout" icon="icon_layout" />
<buttonset columns="2" id="position">
<item text="@.left" />
<item text="@.right" />
<item text="@.bottom" hspan="2" />
</buttonset>
</hbox>
</vbox> </vbox>
<vbox> <vbox>
<separator text="@.frame_header" left="true" horizontal="true" /> <separator text="@.frame_header" left="true" horizontal="true" />

View File

@ -520,6 +520,7 @@ target_sources(app-lib PRIVATE
commands/tileset_mode.cpp commands/tileset_mode.cpp
commands/toggle_other_layers_opacity.cpp commands/toggle_other_layers_opacity.cpp
commands/toggle_play_option.cpp commands/toggle_play_option.cpp
commands/toggle_workspace_layout.cpp
console.cpp console.cpp
context.cpp context.cpp
context_flags.cpp context_flags.cpp
@ -609,6 +610,7 @@ target_sources(app-lib PRIVATE
ui/context_bar.cpp ui/context_bar.cpp
ui/dithering_selector.cpp ui/dithering_selector.cpp
ui/doc_view.cpp ui/doc_view.cpp
ui/dock.cpp
ui/drop_down_button.cpp ui/drop_down_button.cpp
ui/dynamics_popup.cpp ui/dynamics_popup.cpp
ui/editor/brush_preview.cpp ui/editor/brush_preview.cpp
@ -653,6 +655,9 @@ target_sources(app-lib PRIVATE
ui/input_chain.cpp ui/input_chain.cpp
ui/keyboard_shortcuts.cpp ui/keyboard_shortcuts.cpp
ui/layer_frame_comboboxes.cpp ui/layer_frame_comboboxes.cpp
ui/layout.cpp
ui/layouts.cpp
ui/layout_selector.cpp
ui/main_menu_bar.cpp ui/main_menu_bar.cpp
ui/main_window.cpp ui/main_window.cpp
ui/mini_help_button.cpp ui/mini_help_button.cpp

View File

@ -1,5 +1,5 @@
// Aseprite // Aseprite
// Copyright (C) 2018-2023 Igara Studio S.A. // Copyright (C) 2018-2024 Igara Studio S.A.
// Copyright (C) 2001-2018 David Capello // Copyright (C) 2001-2018 David Capello
// //
// This program is distributed under the terms of // This program is distributed under the terms of
@ -174,6 +174,7 @@ FOR_EACH_COMMAND(TogglePreview)
FOR_EACH_COMMAND(ToggleRewindOnStop) FOR_EACH_COMMAND(ToggleRewindOnStop)
FOR_EACH_COMMAND(ToggleTilesMode) FOR_EACH_COMMAND(ToggleTilesMode)
FOR_EACH_COMMAND(ToggleTimelineThumbnails) FOR_EACH_COMMAND(ToggleTimelineThumbnails)
FOR_EACH_COMMAND(ToggleWorkspaceLayout)
FOR_EACH_COMMAND(Undo) FOR_EACH_COMMAND(Undo)
FOR_EACH_COMMAND(UndoHistory) FOR_EACH_COMMAND(UndoHistory)
FOR_EACH_COMMAND(UnlinkCel) FOR_EACH_COMMAND(UnlinkCel)

View File

@ -0,0 +1,47 @@
// Aseprite
// Copyright (c) 2024 Igara Studio S.A.
//
// 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/app.h"
#include "app/commands/command.h"
#include "app/ui/layout_selector.h"
#include "app/ui/main_window.h"
namespace app {
class ToggleWorkspaceLayoutCommand : public Command {
public:
ToggleWorkspaceLayoutCommand();
protected:
bool onChecked(Context* ctx) override;
void onExecute(Context* ctx) override;
};
ToggleWorkspaceLayoutCommand::ToggleWorkspaceLayoutCommand()
: Command(CommandId::ToggleWorkspaceLayout(), CmdUIOnlyFlag)
{
}
bool ToggleWorkspaceLayoutCommand::onChecked(Context* ctx)
{
return App::instance()->mainWindow()->layoutSelector()->isSelectorVisible();
}
void ToggleWorkspaceLayoutCommand::onExecute(Context* ctx)
{
App::instance()->mainWindow()->layoutSelector()->switchSelectorFromCommand();
}
Command* CommandFactory::createToggleWorkspaceLayoutCommand()
{
return new ToggleWorkspaceLayoutCommand();
}
} // namespace app

View File

@ -1095,6 +1095,9 @@ public:
gifframe_t nframes = totalFrames(); gifframe_t nframes = totalFrames();
for (gifframe_t gifFrame = 0; gifFrame < nframes; ++gifFrame) { for (gifframe_t gifFrame = 0; gifFrame < nframes; ++gifFrame) {
ASSERT(frame_it != frame_end); ASSERT(frame_it != frame_end);
if (m_fop->isStop())
break;
frame_t frame = *frame_it; frame_t frame = *frame_it;
++frame_it; ++frame_it;

View File

@ -127,12 +127,13 @@ struct Dialog {
int showRef = LUA_REFNIL; int showRef = LUA_REFNIL;
lua_State* L = nullptr; lua_State* L = nullptr;
Dialog(const ui::Window::Type windowType, const std::string& title) Dialog(const ui::Window::Type windowType, const std::string& title, bool sizeable)
: window(windowType, title) : window(windowType, title)
, grid(2, false) , grid(2, false)
, currentGrid(&grid) , currentGrid(&grid)
{ {
window.addChild(&grid); window.addChild(&grid);
window.setSizeable(sizeable);
all_dialogs.push_back(this); all_dialogs.push_back(this);
} }
@ -365,6 +366,7 @@ int Dialog_new(lua_State* L)
// Get the title and the type of window (with or without title bar) // Get the title and the type of window (with or without title bar)
ui::Window::Type windowType = ui::Window::WithTitleBar; ui::Window::Type windowType = ui::Window::WithTitleBar;
std::string title = "Script"; std::string title = "Script";
bool sizeable = true;
if (lua_isstring(L, 1)) { if (lua_isstring(L, 1)) {
title = lua_tostring(L, 1); title = lua_tostring(L, 1);
} }
@ -378,9 +380,14 @@ int Dialog_new(lua_State* L)
if (type != LUA_TNIL && lua_toboolean(L, -1)) if (type != LUA_TNIL && lua_toboolean(L, -1))
windowType = ui::Window::WithoutTitleBar; windowType = ui::Window::WithoutTitleBar;
lua_pop(L, 1); lua_pop(L, 1);
type = lua_getfield(L, 1, "resizeable");
if (type != LUA_TNIL && !lua_toboolean(L, -1))
sizeable = false;
lua_pop(L, 1);
} }
auto dlg = push_new<Dialog>(L, windowType, title); auto dlg = push_new<Dialog>(L, windowType, title, sizeable);
// The uservalue of the dialog userdata will contain a table that // The uservalue of the dialog userdata will contain a table that
// stores all the callbacks to handle events. As these callbacks can // stores all the callbacks to handle events. As these callbacks can
@ -1509,6 +1516,10 @@ int Dialog_modify(lua_State* L)
type = lua_getfield(L, 2, "text"); type = lua_getfield(L, 2, "text");
if (const char* s = lua_tostring(L, -1)) { if (const char* s = lua_tostring(L, -1)) {
widget->setText(s); widget->setText(s);
// Re-process mnemonics for buttons
if (widget->type() == WidgetType::kButtonWidget)
widget->processMnemonicFromText();
relayout = true; relayout = true;
} }
lua_pop(L, 1); lua_pop(L, 1);

View File

@ -172,6 +172,7 @@ int Plugin_newCommand(lua_State* L)
if (!group.empty() && App::instance()->isGui()) { // On CLI menus do not make sense if (!group.empty() && App::instance()->isGui()) { // On CLI menus do not make sense
if (auto appMenus = AppMenus::instance()) { if (auto appMenus = AppMenus::instance()) {
auto menuItem = std::make_unique<AppMenuItem>(title, id); auto menuItem = std::make_unique<AppMenuItem>(title, id);
menuItem->processMnemonicFromText();
appMenus->addMenuItemIntoGroup(group, std::move(menuItem)); appMenus->addMenuItemIntoGroup(group, std::move(menuItem));
} }
} }

View File

@ -72,6 +72,7 @@
#include "ui/menu.h" #include "ui/menu.h"
#include "ui/message.h" #include "ui/message.h"
#include "ui/paint_event.h" #include "ui/paint_event.h"
#include "ui/resize_event.h"
#include "ui/separator.h" #include "ui/separator.h"
#include "ui/splitter.h" #include "ui/splitter.h"
#include "ui/system.h" #include "ui/system.h"
@ -138,8 +139,8 @@ void ColorBar::ScrollableView::onInitTheme(InitThemeEvent& ev)
ColorBar* ColorBar::m_instance = NULL; ColorBar* ColorBar::m_instance = NULL;
ColorBar::ColorBar(int align, TooltipManager* tooltipManager) ColorBar::ColorBar(TooltipManager* tooltipManager)
: Box(align) : Box(VERTICAL)
, m_editPal(1) , m_editPal(1)
, m_buttons(int(PalButton::MAX)) , m_buttons(int(PalButton::MAX))
, m_tilesButton(1) , m_tilesButton(1)
@ -299,7 +300,7 @@ ColorBar::ColorBar(int align, TooltipManager* tooltipManager)
InitTheme.connect([this, fgBox, bgBox] { InitTheme.connect([this, fgBox, bgBox] {
auto theme = SkinTheme::get(this); auto theme = SkinTheme::get(this);
setBorder(gfx::Border(2 * guiscale(), 0, 0, 0)); setBorder(gfx::Border(0));
setChildSpacing(2 * guiscale()); setChildSpacing(2 * guiscale());
m_fgColor.resetSizeHint(); m_fgColor.resetSizeHint();
@ -644,6 +645,18 @@ void ColorBar::onSizeHint(ui::SizeHintEvent& ev)
Box::onSizeHint(ev); Box::onSizeHint(ev);
} }
void ColorBar::onResize(ui::ResizeEvent& ev)
{
// Docked at left side
// TODO improve this how this is calculated
if (ev.bounds().x == 0)
setBorder(gfx::Border(2 * guiscale(), 0, 0, 0));
else
setBorder(gfx::Border(0, 0, 2 * guiscale(), 0));
Box::onResize(ev);
}
void ColorBar::onActiveSiteChange(const Site& site) void ColorBar::onActiveSiteChange(const Site& site)
{ {
if (m_lastDocument != site.document()) { if (m_lastDocument != site.document()) {

View File

@ -17,6 +17,7 @@
#include "app/tileset_mode.h" #include "app/tileset_mode.h"
#include "app/ui/button_set.h" #include "app/ui/button_set.h"
#include "app/ui/color_button.h" #include "app/ui/color_button.h"
#include "app/ui/dockable.h"
#include "app/ui/input_chain_element.h" #include "app/ui/input_chain_element.h"
#include "app/ui/palette_view.h" #include "app/ui/palette_view.h"
#include "app/ui/tile_button.h" #include "app/ui/tile_button.h"
@ -50,7 +51,8 @@ class ColorBar : public ui::Box,
public PaletteViewDelegate, public PaletteViewDelegate,
public ContextObserver, public ContextObserver,
public DocObserver, public DocObserver,
public InputChainElement { public InputChainElement,
public Dockable {
static ColorBar* m_instance; static ColorBar* m_instance;
public: public:
@ -65,7 +67,7 @@ public:
static ColorBar* instance() { return m_instance; } static ColorBar* instance() { return m_instance; }
ColorBar(int align, ui::TooltipManager* tooltipManager); ColorBar(ui::TooltipManager* tooltipManager);
~ColorBar(); ~ColorBar();
void setPixelFormat(doc::PixelFormat pixelFormat); void setPixelFormat(doc::PixelFormat pixelFormat);
@ -123,10 +125,18 @@ public:
bool onClear(Context* ctx) override; bool onClear(Context* ctx) override;
void onCancel(Context* ctx) override; void onCancel(Context* ctx) override;
// Dockable impl
int dockableAt() const override
{
// TODO split the ColorBar in different dockable widgets
return ui::LEFT | ui::RIGHT | ui::EXPANSIVE;
}
obs::signal<void()> ChangeSelection; obs::signal<void()> ChangeSelection;
protected: protected:
void onSizeHint(ui::SizeHintEvent& ev) override; void onSizeHint(ui::SizeHintEvent& ev) override;
void onResize(ui::ResizeEvent& ev) override;
void onAppPaletteChange(); void onAppPaletteChange();
void onFocusPaletteView(ui::Message* msg); void onFocusPaletteView(ui::Message* msg);
void onFocusTilesView(ui::Message* msg); void onFocusTilesView(ui::Message* msg);

View File

@ -53,7 +53,7 @@ ConfigureTimelinePopup::ConfigureTimelinePopup()
m_box = new app::gen::TimelineConf(); m_box = new app::gen::TimelineConf();
addChild(m_box); addChild(m_box);
m_box->position()->ItemChange.connect([this] { onChangePosition(); }); m_box->layout()->Click.connect([this] { onWorkspaceLayout(); });
m_box->firstFrame()->Change.connect([this] { onChangeFirstFrame(); }); m_box->firstFrame()->Change.connect([this] { onChangeFirstFrame(); });
m_box->merge()->Click.connect([this] { onChangeType(); }); m_box->merge()->Click.connect([this] { onChangeType(); });
m_box->tint()->Click.connect([this] { onChangeType(); }); m_box->tint()->Click.connect([this] { onChangeType(); });
@ -93,15 +93,6 @@ void ConfigureTimelinePopup::updateWidgetsFromCurrentSettings()
DocumentPreferences& docPref = this->docPref(); DocumentPreferences& docPref = this->docPref();
base::ScopedValue lockUpdates(m_lockUpdates, true); base::ScopedValue lockUpdates(m_lockUpdates, true);
auto position = Preferences::instance().general.timelinePosition();
int selItem = 2;
switch (position) {
case gen::TimelinePosition::LEFT: selItem = 0; break;
case gen::TimelinePosition::RIGHT: selItem = 1; break;
case gen::TimelinePosition::BOTTOM: selItem = 2; break;
}
m_box->position()->setSelectedItem(selItem, false);
m_box->firstFrame()->setTextf("%d", docPref.timeline.firstFrame()); m_box->firstFrame()->setTextf("%d", docPref.timeline.firstFrame());
switch (docPref.onionskin.type()) { switch (docPref.onionskin.type()) {
@ -147,17 +138,10 @@ bool ConfigureTimelinePopup::onProcessMessage(ui::Message* msg)
return PopupWindow::onProcessMessage(msg); return PopupWindow::onProcessMessage(msg);
} }
void ConfigureTimelinePopup::onChangePosition() void ConfigureTimelinePopup::onWorkspaceLayout()
{ {
gen::TimelinePosition newTimelinePos = gen::TimelinePosition::BOTTOM; UIContext::instance()->executeCommand(
Commands::instance()->byId(CommandId::ToggleWorkspaceLayout()));
int selITem = m_box->position()->selectedItem();
switch (selITem) {
case 0: newTimelinePos = gen::TimelinePosition::LEFT; break;
case 1: newTimelinePos = gen::TimelinePosition::RIGHT; break;
case 2: newTimelinePos = gen::TimelinePosition::BOTTOM; break;
}
Preferences::instance().general.timelinePosition(newTimelinePos);
} }
void ConfigureTimelinePopup::onChangeFirstFrame() void ConfigureTimelinePopup::onChangeFirstFrame()

View File

@ -1,5 +1,5 @@
// Aseprite // Aseprite
// Copyright (C) 2025 Igara Studio S.A. // Copyright (C) 2022-2025 Igara Studio S.A.
// Copyright (C) 2001-2017 David Capello // Copyright (C) 2001-2017 David Capello
// //
// This program is distributed under the terms of // This program is distributed under the terms of
@ -33,7 +33,7 @@ public:
protected: protected:
bool onProcessMessage(ui::Message* msg) override; bool onProcessMessage(ui::Message* msg) override;
void onChangePosition(); void onWorkspaceLayout();
void onChangeFirstFrame(); void onChangeFirstFrame();
void onChangeType(); void onChangeType();
void onOpacity(); void onOpacity();

View File

@ -1962,6 +1962,12 @@ void ContextBar::onInitTheme(ui::InitThemeEvent& ev)
auto theme = SkinTheme::get(this); auto theme = SkinTheme::get(this);
gfx::Border border = this->border(); gfx::Border border = this->border();
border.bottom(2 * guiscale()); border.bottom(2 * guiscale());
// Docked at the left side
// TODO improve this how this is calculated
if (bounds().x == 0)
border.left(border.left() + 2 * guiscale());
setBorder(border); setBorder(border);
setBgColor(theme->colors.workspace()); setBgColor(theme->colors.workspace());
m_sprayLabel->setStyle(theme->styles.miniLabel()); m_sprayLabel->setStyle(theme->styles.miniLabel());

View File

@ -17,6 +17,7 @@
#include "app/tools/tool_loop_modifiers.h" #include "app/tools/tool_loop_modifiers.h"
#include "app/ui/context_bar_observer.h" #include "app/ui/context_bar_observer.h"
#include "app/ui/doc_observer_widget.h" #include "app/ui/doc_observer_widget.h"
#include "app/ui/dockable.h"
#include "app/ui/font_entry.h" #include "app/ui/font_entry.h"
#include "doc/brush.h" #include "doc/brush.h"
#include "obs/connection.h" #include "obs/connection.h"
@ -60,7 +61,8 @@ class Transformation;
class ContextBar : public DocObserverWidget<ui::HBox>, class ContextBar : public DocObserverWidget<ui::HBox>,
public obs::observable<ContextBarObserver>, public obs::observable<ContextBarObserver>,
public tools::ActiveToolObserver { public tools::ActiveToolObserver,
public Dockable {
public: public:
ContextBar(ui::TooltipManager* tooltipManager, ColorBar* colorBar); ContextBar(ui::TooltipManager* tooltipManager, ColorBar* colorBar);
~ContextBar(); ~ContextBar();
@ -100,6 +102,10 @@ public:
// For freehand with dynamics // For freehand with dynamics
const tools::DynamicsOptions& getDynamics() const; const tools::DynamicsOptions& getDynamics() const;
// Dockable impl
int dockableAt() const override { return ui::TOP | ui::BOTTOM; }
int dockHandleSide() const override { return ui::LEFT; }
// Signals // Signals
obs::signal<void()> BrushChange; obs::signal<void()> BrushChange;
obs::signal<void(const FontInfo&, FontEntry::From)> FontChange; obs::signal<void(const FontInfo&, FontEntry::From)> FontChange;

866
src/app/ui/dock.cpp Normal file
View File

@ -0,0 +1,866 @@
// Aseprite
// Copyright (C) 2021-2025 Igara Studio S.A.
//
// 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/dock.h"
#include "app/app.h"
#include "app/i18n/strings.h"
#include "app/ini_file.h"
#include "app/modules/gfx.h"
#include "app/pref/preferences.h"
#include "app/ui/dockable.h"
#include "app/ui/layout_selector.h"
#include "app/ui/main_window.h"
#include "app/ui/skin/skin_theme.h"
#include "os/system.h"
#include "ui/cursor_type.h"
#include "ui/label.h"
#include "ui/menu.h"
#include "ui/message.h"
#include "ui/paint_event.h"
#include "ui/resize_event.h"
#include "ui/scale.h"
#include "ui/size_hint_event.h"
#include "ui/system.h"
#include "ui/widget.h"
namespace app {
using namespace app::skin;
using namespace ui;
namespace {
enum { kTopIndex, kBottomIndex, kLeftIndex, kRightIndex, kCenterIndex };
int side_index(int side)
{
switch (side) {
case ui::TOP: return kTopIndex;
case ui::BOTTOM: return kBottomIndex;
case ui::LEFT: return kLeftIndex;
case ui::RIGHT: return kRightIndex;
}
return kCenterIndex; // ui::CENTER
}
int side_from_index(int index)
{
switch (index) {
case kTopIndex: return ui::TOP;
case kBottomIndex: return ui::BOTTOM;
case kLeftIndex: return ui::LEFT;
case kRightIndex: return ui::RIGHT;
}
return ui::CENTER; // kCenterIndex
}
} // anonymous namespace
// TODO: Duplicated from main_window.cpp
static constexpr auto kLegacyLayoutMainWindowSection = "layout:main_window";
static constexpr auto kLegacyLayoutTimelineSplitter = "timeline_splitter";
Dock::DropzonePlaceholder::DropzonePlaceholder(Widget* dragWidget, const gfx::Point& mousePosition)
: Widget(kGenericWidget)
{
setExpansive(true);
setSizeHint(dragWidget->sizeHint());
setMinSize(dragWidget->size());
m_mouseOffset = mousePosition - dragWidget->bounds().origin();
const os::SurfaceRef surface = os::System::instance()->makeRgbaSurface(dragWidget->size().w,
dragWidget->size().h);
{
const os::SurfaceLock lock(surface.get());
Paint paint;
paint.color(gfx::rgba(0, 0, 0, 0));
paint.style(os::Paint::Fill);
surface->drawRect(gfx::Rect(0, 0, surface->width(), surface->height()), paint);
}
{
Graphics g(display(), surface, 0, 0);
g.setFont(font());
Paint paint;
paint.color(gfx::rgba(0, 0, 0, 200));
// TODO: This will render any open things, especially the preview editor, need to close or hide
// that for a frame or paint the widget itself to a surface instead of croppping the backLayer.
auto backLayerSurface = display()->backLayer()->surface();
g.drawSurface(backLayerSurface.get(),
dragWidget->bounds(),
gfx::Rect(0, 0, surface->width(), surface->height()),
os::Sampling(),
&paint);
}
m_floatingUILayer = UILayer::Make();
m_floatingUILayer->setSurface(surface);
m_floatingUILayer->setPosition(dragWidget->bounds().origin());
display()->addLayer(m_floatingUILayer);
}
Dock::DropzonePlaceholder::~DropzonePlaceholder()
{
display()->removeLayer(m_floatingUILayer);
}
void Dock::DropzonePlaceholder::setGhostPosition(const gfx::Point& position) const
{
ASSERT(m_floatingUILayer);
display()->dirtyRect(m_floatingUILayer->bounds());
m_floatingUILayer->setPosition(position - m_mouseOffset);
display()->dirtyRect(m_floatingUILayer->bounds());
}
void Dock::DropzonePlaceholder::onPaint(PaintEvent& ev)
{
Graphics* g = ev.graphics();
gfx::Rect bounds = clientBounds();
g->fillRect(bgColor(), bounds);
bounds.shrink(2 * guiscale());
const auto* theme = SkinTheme::get(this);
const gfx::Color color = theme->colors.workspaceText();
g->drawRect(color, bounds);
g->drawLine(color, bounds.center(), bounds.origin());
g->drawLine(color, bounds.center(), bounds.point2());
g->drawLine(color, bounds.center(), bounds.point2() - gfx::Point(bounds.w, 0));
g->drawLine(color, bounds.center(), bounds.origin() + gfx::Point(bounds.w, 0));
g->drawRect(
color,
gfx::Rect(bounds.center() - gfx::Point(2, 2) * guiscale(), gfx::Size(4, 4) * guiscale()));
}
Dock::Dock()
{
for (int i = 0; i < kSides; ++i) {
m_sides[i] = nullptr;
m_aligns[i] = 0;
m_sizes[i] = gfx::Size(0, 0);
}
initTheme();
}
void Dock::setCustomizing(bool enable, bool doLayout)
{
m_customizing = enable;
for (int i = 0; i < kSides; ++i) {
auto* child = m_sides[i];
if (!child)
continue;
if (auto* subdock = dynamic_cast<Dock*>(child))
subdock->setCustomizing(enable, false);
}
if (doLayout)
layout();
}
void Dock::resetDocks()
{
for (int i = 0; i < kSides; ++i) {
auto* child = m_sides[i];
if (!child)
continue;
if (auto* subdock = dynamic_cast<Dock*>(child)) {
subdock->resetDocks();
if (subdock->m_autoDelete)
delete subdock;
}
m_sides[i] = nullptr;
}
removeAllChildren();
}
void Dock::dock(int side, ui::Widget* widget, const gfx::Size& prefSize)
{
ASSERT(widget);
const int i = side_index(side);
if (!m_sides[i]) {
setSide(i, widget);
addChild(widget);
if (prefSize != gfx::Size(0, 0))
m_sizes[i] = prefSize;
}
else if (auto subdock = dynamic_cast<Dock*>(m_sides[i])) {
subdock->dock(CENTER, widget, prefSize);
}
else {
ASSERT(false); // Docking failure!
}
}
void Dock::dockRelativeTo(ui::Widget* relative,
int side,
ui::Widget* widget,
const gfx::Size& prefSize)
{
ASSERT(relative);
Widget* parent = relative->parent();
ASSERT(parent);
auto* subdock = new Dock;
subdock->m_autoDelete = true;
subdock->m_customizing = m_customizing;
parent->replaceChild(relative, subdock);
subdock->dock(CENTER, relative);
subdock->dock(side, widget, prefSize);
// Fix the m_sides item if the parent is a Dock
if (auto* relativeDock = dynamic_cast<Dock*>(parent)) {
for (int i = 0; i < kSides; ++i) {
if (relativeDock->m_sides[i] == relative) {
relativeDock->setSide(i, subdock);
break;
}
}
}
}
void Dock::undock(Widget* widget)
{
Widget* parent = widget->parent();
if (!parent)
return; // Already undocked
if (auto* parentDock = dynamic_cast<Dock*>(parent)) {
parentDock->removeChild(widget);
for (int i = 0; i < kSides; ++i) {
if (parentDock->m_sides[i] == widget) {
parentDock->setSide(i, nullptr);
m_sizes[i] = gfx::Size();
break;
}
}
if (parentDock != this && parentDock->children().empty()) {
undock(parentDock);
}
}
else {
parent->removeChild(widget);
}
}
int Dock::whichSideChildIsDocked(const ui::Widget* widget) const
{
for (int i = 0; i < kSides; ++i)
if (m_sides[i] == widget)
return side_from_index(i);
return 0;
}
const gfx::Size Dock::getUserDefinedSizeAtSide(int side) const
{
int i = side_index(side);
// Only EXPANSIVE sides can be user-defined (has a splitter so the
// user can expand or shrink it)
if (m_aligns[i] & EXPANSIVE)
return m_sizes[i];
return gfx::Size();
}
Dock* Dock::subdock(int side)
{
int i = side_index(side);
if (auto* subdock = dynamic_cast<Dock*>(m_sides[i]))
return subdock;
auto* oldWidget = m_sides[i];
auto* newSubdock = new Dock;
newSubdock->m_autoDelete = true;
newSubdock->m_customizing = m_customizing;
setSide(i, newSubdock);
if (oldWidget) {
replaceChild(oldWidget, newSubdock);
newSubdock->dock(CENTER, oldWidget);
}
else
addChild(newSubdock);
return newSubdock;
}
void Dock::onSizeHint(ui::SizeHintEvent& ev)
{
gfx::Size fitIn = ev.fitInSize();
gfx::Size sz;
for (int i = 0; i < kSides; ++i) {
auto* widget = m_sides[i];
if (!widget || !widget->isVisible() || widget->isDecorative())
continue;
const int spacing = (m_aligns[i] & EXPANSIVE ? childSpacing() : 0);
const auto hint = (m_aligns[i] & EXPANSIVE ? m_sizes[i] : widget->sizeHint(fitIn));
switch (i) {
case kTopIndex:
case kBottomIndex:
sz.h += hint.h;
fitIn.h = std::max(0, fitIn.h - hint.h);
if (spacing > 0) {
sz.h += spacing;
fitIn.h = std::max(0, fitIn.h - spacing);
}
break;
case kLeftIndex:
case kRightIndex:
sz.w += hint.w;
fitIn.w = std::max(0, fitIn.w - hint.w);
if (spacing > 0) {
sz.w += spacing;
fitIn.w = std::max(0, fitIn.w - spacing);
}
break;
case kCenterIndex:
sz += gfx::Size(std::max(hint.w, std::max(m_sizes[kTopIndex].w, m_sizes[kBottomIndex].w)),
std::max(hint.h, std::max(m_sizes[kLeftIndex].h, m_sizes[kRightIndex].h)));
break;
}
}
sz += border();
ev.setSizeHint(sz);
}
void Dock::onResize(ui::ResizeEvent& ev)
{
gfx::Rect bounds = ev.bounds();
setBoundsQuietly(bounds);
bounds = childrenBounds();
updateDockVisibility();
forEachSide(bounds,
[this](ui::Widget* widget,
const gfx::Rect& widgetBounds,
const gfx::Rect& separator,
const int index) {
gfx::Rect rc = widgetBounds;
auto th = textHeight();
if (isCustomizing()) {
int handleSide = 0;
if (auto* dockable = dynamic_cast<Dockable*>(widget))
handleSide = dockable->dockHandleSide();
switch (handleSide) {
case ui::TOP:
rc.y += th;
rc.h -= th;
break;
case ui::LEFT:
rc.x += th;
rc.w -= th;
break;
}
}
widget->setBounds(rc);
});
}
void Dock::onPaint(ui::PaintEvent& ev)
{
Graphics* g = ev.graphics();
const gfx::Rect& bounds = clientBounds();
g->fillRect(bgColor(), bounds);
if (isCustomizing()) {
forEachSide(bounds,
[this, g](ui::Widget* widget,
const gfx::Rect& widgetBounds,
const gfx::Rect& separator,
const int index) {
gfx::Rect rc = widgetBounds;
auto th = textHeight();
if (isCustomizing()) {
auto* theme = SkinTheme::get(this);
const gfx::Color color = theme->colors.workspaceText();
int handleSide = 0;
if (auto* dockable = dynamic_cast<Dockable*>(widget))
handleSide = dockable->dockHandleSide();
switch (handleSide) {
case ui::TOP:
rc.h = th;
for (int y = rc.y; y + 1 < rc.y2(); y += 2)
g->drawHLine(color,
rc.x + widget->border().left(),
y,
rc.w - widget->border().width());
break;
case ui::LEFT:
rc.w = th;
for (int x = rc.x; x + 1 < rc.x2(); x += 2)
g->drawVLine(color,
x,
rc.y + widget->border().top(),
rc.h - widget->border().height());
break;
}
}
});
}
}
void Dock::onInitTheme(ui::InitThemeEvent& ev)
{
Widget::onInitTheme(ev);
setBorder(gfx::Border(0));
setChildSpacing(4 * ui::guiscale());
for (int i = 0; i < kSides; ++i) {
Widget* widget = m_sides[i];
if (widget)
widget->initTheme();
}
}
bool Dock::onProcessMessage(ui::Message* msg)
{
switch (msg->type()) {
case kMouseDownMessage: {
auto* mouseMessage = static_cast<MouseMessage*>(msg);
const gfx::Point& pos = mouseMessage->position();
if (m_hit.sideIndex >= 0 || m_hit.dockable) {
m_startPos = pos;
if (m_hit.sideIndex >= 0)
m_startSize = m_sizes[m_hit.sideIndex];
captureMouse();
if (m_hit.dockable && !mouseMessage->right()) {
m_dragging = true;
}
return true;
}
break;
}
case kMouseMoveMessage: {
if (hasCapture()) {
const gfx::Point& pos = static_cast<MouseMessage*>(msg)->position();
if (m_dropzonePlaceholder)
m_dropzonePlaceholder->setGhostPosition(pos);
if (m_hit.sideIndex >= 0) {
if (!display()->bounds().contains(pos) ||
(m_hit.widget && m_hit.widget->parent() &&
!m_hit.widget->parent()->bounds().contains(pos)))
break; // Do not handle anything outside bounds.
gfx::Size& sz = m_sizes[m_hit.sideIndex];
gfx::Size minSize(16 * guiscale(), 16 * guiscale());
if (m_hit.widget) {
minSize.w = std::max(m_hit.widget->minSize().w, minSize.w);
minSize.h = std::max(m_hit.widget->minSize().h, minSize.h);
}
switch (m_hit.sideIndex) {
case kTopIndex: sz.h = std::max(m_startSize.h + pos.y - m_startPos.y, minSize.h); break;
case kBottomIndex:
sz.h = std::max(m_startSize.h - pos.y + m_startPos.y, minSize.h);
break;
case kLeftIndex:
sz.w = std::max(m_startSize.w + pos.x - m_startPos.x, minSize.w);
break;
case kRightIndex:
sz.w = std::max(m_startSize.w - pos.x + m_startPos.x, minSize.w);
break;
}
layout();
Resize();
}
else if (m_hit.dockable && m_dragging) {
invalidate();
auto* parentDock = dynamic_cast<Dock*>(m_hit.widget->parent());
ASSERT(parentDock);
if (!parentDock)
break;
if (!m_dropzonePlaceholder)
m_dropzonePlaceholder.reset(new DropzonePlaceholder(m_hit.widget, pos));
auto dockedAt = parentDock->whichSideChildIsDocked(m_hit.widget);
const auto& bounds = parentDock->bounds();
if (!bounds.contains(pos))
break; // Do not handle anything outside the bounds of the dock.
const int kBufferZone =
std::max(12 * guiscale(), std::min(m_hit.widget->size().w, m_hit.widget->size().h));
int newTargetSide = -1;
if (m_hit.dockable->dockableAt() & LEFT && !(dockedAt & LEFT) &&
pos.x < bounds.x + kBufferZone) {
newTargetSide = LEFT;
}
else if (m_hit.dockable->dockableAt() & RIGHT && !(dockedAt & RIGHT) &&
pos.x > (bounds.w - kBufferZone)) {
newTargetSide = RIGHT;
}
else if (m_hit.dockable->dockableAt() & TOP && !(dockedAt & TOP) &&
pos.y < bounds.y + kBufferZone) {
newTargetSide = TOP;
}
else if (m_hit.dockable->dockableAt() & BOTTOM && !(dockedAt & BOTTOM) &&
pos.y > (bounds.h - kBufferZone)) {
newTargetSide = BOTTOM;
}
if (m_hit.targetSide == newTargetSide)
break;
m_hit.targetSide = newTargetSide;
// Always undock the placeholder
if (m_dropzonePlaceholder && m_dropzonePlaceholder->parent()) {
auto* placeholderCurrentDock = dynamic_cast<Dock*>(m_dropzonePlaceholder->parent());
placeholderCurrentDock->undock(m_dropzonePlaceholder.get());
}
if (m_dropzonePlaceholder && m_hit.targetSide != -1) {
parentDock->dock(m_hit.targetSide,
m_dropzonePlaceholder.get(),
m_hit.widget->sizeHint());
}
layout();
}
}
break;
}
case kMouseUpMessage: {
if (hasCapture()) {
releaseMouse();
if (m_dropzonePlaceholder && m_dropzonePlaceholder->parent()) {
// Always undock the dropzone placeholder to avoid dangling sizes.
auto* placeholderCurrentDock = dynamic_cast<Dock*>(m_dropzonePlaceholder->parent());
placeholderCurrentDock->undock(m_dropzonePlaceholder.get());
}
if (m_hit.dockable) {
auto* dockableWidget = dynamic_cast<Widget*>(m_hit.dockable);
auto* widgetDock = dynamic_cast<Dock*>(dockableWidget->parent());
int currentSide = widgetDock->whichSideChildIsDocked(dockableWidget);
assert(dockableWidget && widgetDock);
const auto* mouseMessage = static_cast<MouseMessage*>(msg);
if (mouseMessage->right() && !m_dragging) {
Menu menu;
MenuItem left(Strings::dock_left());
MenuItem right(Strings::dock_right());
MenuItem top(Strings::dock_top());
MenuItem bottom(Strings::dock_bottom());
if (m_hit.dockable->dockableAt() & ui::LEFT) {
menu.addChild(&left);
}
if (m_hit.dockable->dockableAt() & ui::RIGHT) {
menu.addChild(&right);
}
if (m_hit.dockable->dockableAt() & ui::TOP) {
menu.addChild(&top);
}
if (m_hit.dockable->dockableAt() & ui::BOTTOM) {
menu.addChild(&bottom);
}
switch (currentSide) {
case ui::LEFT: left.setEnabled(false); break;
case ui::RIGHT: right.setEnabled(false); break;
case ui::TOP: top.setEnabled(false); break;
case ui::BOTTOM: bottom.setEnabled(false); break;
}
left.Click.connect([&] { redockWidget(widgetDock, dockableWidget, ui::LEFT); });
right.Click.connect([&] { redockWidget(widgetDock, dockableWidget, ui::RIGHT); });
top.Click.connect([&] { redockWidget(widgetDock, dockableWidget, ui::TOP); });
bottom.Click.connect([&] { redockWidget(widgetDock, dockableWidget, ui::BOTTOM); });
menu.showPopup(mouseMessage->position(), display());
return false;
}
else if (m_hit.targetSide > 0 && m_dragging) {
ASSERT(m_hit.dockable->dockableAt() & m_hit.targetSide);
redockWidget(widgetDock, dockableWidget, m_hit.targetSide);
m_dropzonePlaceholder = nullptr;
m_dragging = false;
m_hit = Hit();
return false;
}
}
m_dropzonePlaceholder = nullptr;
m_dragging = false;
// Call UserResizedDock signal after resizing a Dock splitter
if (m_hit.sideIndex >= 0)
onUserResizedDock();
m_hit = Hit();
}
break;
}
case kSetCursorMessage: {
const gfx::Point& pos = static_cast<MouseMessage*>(msg)->position();
ui::CursorType cursor = ui::kArrowCursor;
if (!hasCapture())
m_hit = calcHit(pos);
if (m_hit.sideIndex >= 0) {
switch (m_hit.sideIndex) {
case kTopIndex:
case kBottomIndex: cursor = ui::kSizeNSCursor; break;
case kLeftIndex:
case kRightIndex: cursor = ui::kSizeWECursor; break;
}
}
else if (m_hit.dockable && m_hit.targetSide == -1) {
cursor = ui::kMoveCursor;
}
ui::set_mouse_cursor(cursor);
return true;
}
}
return Widget::onProcessMessage(msg);
}
void Dock::onUserResizedDock()
{
// Generate the UserResizedDock signal, this can be used to know
// when the user modified the dock configuration to save the new
// layout in a user/preference file.
UserResizedDock();
// Send the same notification for the parent (as probably eh
// MainWindow is listening the signal of just the root dock).
if (auto* parentDock = dynamic_cast<Dock*>(parent())) {
parentDock->onUserResizedDock();
}
}
void Dock::setSide(const int i, Widget* newWidget)
{
m_sides[i] = newWidget;
m_aligns[i] = calcAlign(i);
if (newWidget) {
m_sizes[i] = newWidget->sizeHint();
}
}
int Dock::calcAlign(const int i)
{
Widget* widget = m_sides[i];
int align = 0;
if (!widget) {
// Do nothing
}
else if (auto* subdock = dynamic_cast<Dock*>(widget)) {
align = subdock->calcAlign(i);
}
else if (auto* dockable2 = dynamic_cast<Dockable*>(widget)) {
align = dockable2->dockableAt();
}
return align;
}
void Dock::updateDockVisibility()
{
bool visible = false;
setVisible(true);
for (int i = 0; i < kSides; ++i) {
Widget* widget = m_sides[i];
if (!widget)
continue;
if (auto* subdock = dynamic_cast<Dock*>(widget)) {
subdock->updateDockVisibility();
}
if (widget->isVisible()) {
visible = true;
}
}
setVisible(visible);
}
void Dock::forEachSide(gfx::Rect bounds,
std::function<void(ui::Widget* widget,
const gfx::Rect& widgetBounds,
const gfx::Rect& separator,
const int index)> f)
{
for (int i = 0; i < kSides; ++i) {
auto* widget = m_sides[i];
if (!widget || !widget->isVisible() || widget->isDecorative()) {
continue;
}
const int spacing = (m_aligns[i] & EXPANSIVE ? childSpacing() : 0);
const gfx::Size sz = (m_aligns[i] & EXPANSIVE ? m_sizes[i] : widget->sizeHint(bounds.size()));
gfx::Rect rc, separator;
switch (i) {
case kTopIndex:
rc = gfx::Rect(bounds.x, bounds.y, bounds.w, sz.h);
bounds.y += rc.h;
bounds.h -= rc.h;
if (spacing > 0) {
separator = gfx::Rect(bounds.x, bounds.y, bounds.w, spacing);
bounds.y += spacing;
bounds.h -= spacing;
}
break;
case kBottomIndex:
rc = gfx::Rect(bounds.x, bounds.y2() - sz.h, bounds.w, sz.h);
bounds.h -= rc.h;
if (spacing > 0) {
separator = gfx::Rect(bounds.x, bounds.y2() - spacing, bounds.w, spacing);
bounds.h -= spacing;
}
break;
case kLeftIndex:
rc = gfx::Rect(bounds.x, bounds.y, sz.w, bounds.h);
bounds.x += rc.w;
bounds.w -= rc.w;
if (spacing > 0) {
separator = gfx::Rect(bounds.x, bounds.y, spacing, bounds.h);
bounds.x += spacing;
bounds.w -= spacing;
}
break;
case kRightIndex:
rc = gfx::Rect(bounds.x2() - sz.w, bounds.y, sz.w, bounds.h);
bounds.w -= rc.w;
if (spacing > 0) {
separator = gfx::Rect(bounds.x2() - spacing, bounds.y, spacing, bounds.h);
bounds.w -= spacing;
}
break;
case kCenterIndex: rc = bounds; break;
}
f(widget, rc, separator, i);
}
}
void Dock::redockWidget(app::Dock* widgetDock, ui::Widget* dockableWidget, const int side)
{
gfx::Size size;
if (dockableWidget->id() == "timeline") {
const gfx::Rect workspaceBounds = widgetDock->bounds();
size.w = 64;
size.h = 64;
const auto timelineSplitterPos =
get_config_double(kLegacyLayoutMainWindowSection, kLegacyLayoutTimelineSplitter, 75.0) /
100.0;
auto pos = gen::TimelinePosition::LEFT;
size.w = (workspaceBounds.w * (1.0 - timelineSplitterPos)) / guiscale();
if (side & RIGHT) {
pos = gen::TimelinePosition::RIGHT;
}
if (side & BOTTOM || side & TOP) {
pos = gen::TimelinePosition::BOTTOM;
size.h = (workspaceBounds.h * (1.0 - timelineSplitterPos)) / guiscale();
}
Preferences::instance().general.timelinePosition(pos);
}
widgetDock->undock(dockableWidget);
widgetDock->dock(side, dockableWidget, size);
App::instance()->mainWindow()->invalidate();
layout();
onUserResizedDock();
}
Dock::Hit Dock::calcHit(const gfx::Point& pos)
{
Hit hit;
forEachSide(childrenBounds(),
[this, pos, &hit](ui::Widget* widget,
const gfx::Rect& widgetBounds,
const gfx::Rect& separator,
const int index) {
if (separator.contains(pos)) {
hit.widget = widget;
hit.sideIndex = index;
}
else if (isCustomizing()) {
auto th = textHeight();
gfx::Rect rc = widgetBounds;
if (auto* dockable = dynamic_cast<Dockable*>(widget)) {
switch (dockable->dockHandleSide()) {
case ui::TOP:
rc.h = th;
if (rc.contains(pos)) {
hit.widget = widget;
hit.dockable = dockable;
}
break;
case ui::LEFT:
rc.w = th;
if (rc.contains(pos)) {
hit.widget = widget;
hit.dockable = dockable;
}
break;
}
}
}
});
return hit;
}
} // namespace app

129
src/app/ui/dock.h Normal file
View File

@ -0,0 +1,129 @@
// Aseprite
// Copyright (C) 2021-2025 Igara Studio S.A.
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifndef APP_UI_DOCK_H_INCLUDED
#define APP_UI_DOCK_H_INCLUDED
#pragma once
#include "app/ui/dockable.h"
#include "gfx/rect.h"
#include "gfx/size.h"
#include "ui/widget.h"
#include <array>
#include <functional>
#include <string>
#include <vector>
namespace app {
class Dockable;
class Dock : public ui::Widget {
public:
static constexpr const int kSides = 5;
class DropzonePlaceholder final : public Widget,
public Dockable {
public:
DropzonePlaceholder(Widget* dragWidget, const gfx::Point& mousePosition);
~DropzonePlaceholder() override;
void setGhostPosition(const gfx::Point& position) const;
private:
void onPaint(ui::PaintEvent& ev) override;
int dockHandleSide() const override { return 0; }
gfx::Point m_mouseOffset;
ui::UILayerRef m_floatingUILayer;
};
Dock();
bool isCustomizing() const { return m_customizing; }
void setCustomizing(bool enable, bool doLayout = true);
void resetDocks();
// side = ui::LEFT, or ui::RIGHT, etc.
void dock(int side, ui::Widget* widget, const gfx::Size& prefSize = gfx::Size());
void dockRelativeTo(ui::Widget* relative,
int side,
ui::Widget* widget,
const gfx::Size& prefSize = gfx::Size());
void undock(ui::Widget* widget);
Dock* subdock(int side);
Dock* top() { return subdock(ui::TOP); }
Dock* bottom() { return subdock(ui::BOTTOM); }
Dock* left() { return subdock(ui::LEFT); }
Dock* right() { return subdock(ui::RIGHT); }
Dock* center() { return subdock(ui::CENTER); }
// Functions useful to query/save the dock layout.
int whichSideChildIsDocked(const ui::Widget* widget) const;
const gfx::Size getUserDefinedSizeAtSide(int side) const;
obs::signal<void()> Resize;
obs::signal<void()> UserResizedDock;
protected:
void onSizeHint(ui::SizeHintEvent& ev) override;
void onResize(ui::ResizeEvent& ev) override;
void onPaint(ui::PaintEvent& ev) override;
void onInitTheme(ui::InitThemeEvent& ev) override;
bool onProcessMessage(ui::Message* msg) override;
void onUserResizedDock();
private:
void setSide(int i, ui::Widget* newWidget);
int calcAlign(int i);
void updateDockVisibility();
void forEachSide(gfx::Rect bounds,
std::function<void(ui::Widget* widget,
const gfx::Rect& widgetBounds,
const gfx::Rect& separator,
const int index)> f);
bool hasVisibleSide(const int i) const { return (m_sides[i] && m_sides[i]->isVisible()); }
void redockWidget(app::Dock* widgetDock, ui::Widget* dockableWidget, const int side);
struct Hit {
ui::Widget* widget = nullptr;
Dockable* dockable = nullptr;
int sideIndex = -1;
int targetSide = -1;
};
Hit calcHit(const gfx::Point& pos);
std::array<Widget*, kSides> m_sides;
std::array<int, kSides> m_aligns;
std::array<gfx::Size, kSides> m_sizes;
bool m_autoDelete = false;
// Use to drag-and-drop stuff (splitters and dockable widgets)
Hit m_hit;
// Used to resize sizes splitters.
gfx::Size m_startSize;
gfx::Point m_startPos;
// True when we paint/can drag-and-drop dockable widgets from handles.
bool m_customizing = false;
// True when we're dragging a widget to attempt to dock it somewhere else.
bool m_dragging = false;
std::unique_ptr<DropzonePlaceholder> m_dropzonePlaceholder;
};
} // namespace app
#endif

37
src/app/ui/dockable.h Normal file
View File

@ -0,0 +1,37 @@
// Aseprite
// Copyright (C) 2021 Igara Studio S.A.
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifndef APP_UI_DOCKABLE_H_INCLUDED
#define APP_UI_DOCKABLE_H_INCLUDED
#pragma once
#include "ui/base.h"
namespace app {
class Dockable {
public:
virtual ~Dockable() {}
// LEFT = can be docked at the left side
// TOP = can be docked at the top
// RIGHT = can be docked at the right side
// BOTTOM = can be docked at the bottom
// CENTER = can be docked at the center
// EXPANSIVE = can be resized (e.g. add a splitter when docked at sides)
virtual int dockableAt() const
{
return ui::LEFT | ui::TOP | ui::RIGHT | ui::BOTTOM | ui::CENTER | ui::EXPANSIVE;
}
// Returns the preferred side where the dock handle to move the
// widget should be.
virtual int dockHandleSide() const { return ui::TOP; }
};
} // namespace app
#endif

View File

@ -36,10 +36,10 @@ void IconButton::setIcon(const skin::SkinPartPtr& part)
void IconButton::onInitTheme(InitThemeEvent& ev) void IconButton::onInitTheme(InitThemeEvent& ev)
{ {
Button::onInitTheme(ev);
auto theme = SkinTheme::get(this); auto theme = SkinTheme::get(this);
setBgColor(theme->colors.menuitemNormalFace()); setBgColor(theme->colors.menuitemNormalFace());
Button::onInitTheme(ev);
} }
void IconButton::onSizeHint(SizeHintEvent& ev) void IconButton::onSizeHint(SizeHintEvent& ev)

222
src/app/ui/layout.cpp Normal file
View File

@ -0,0 +1,222 @@
// Aseprite
// Copyright (C) 2022-2024 Igara Studio S.A.
//
// 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/layout.h"
#include "app/app.h"
#include "app/ui/color_bar.h"
#include "app/ui/context_bar.h"
#include "app/ui/dock.h"
#include "app/ui/main_window.h"
#include "app/ui/status_bar.h"
#include "app/ui/timeline/timeline.h"
#include "app/ui/toolbar.h"
#include "app/ui/workspace.h"
#include "app/xml_document.h"
#include "app/xml_exception.h"
#include "base/convert_to.h"
#include "ui/widget.h"
#include <cstring>
#include <sstream>
namespace app {
using namespace tinyxml2;
static void save_dock_layout(XMLElement* elem, const Dock* dock)
{
for (const auto* child : dock->children()) {
const int side = dock->whichSideChildIsDocked(child);
const gfx::Size size = dock->getUserDefinedSizeAtSide(side);
std::string sideStr;
switch (side) {
case ui::LEFT: sideStr = "left"; break;
case ui::RIGHT: sideStr = "right"; break;
case ui::TOP: sideStr = "top"; break;
case ui::BOTTOM: sideStr = "bottom"; break;
case ui::CENTER:
default:
// Empty side attribute
break;
}
XMLElement* childElem = elem->InsertNewChildElement("");
if (const auto* subdock = dynamic_cast<const Dock*>(child)) {
childElem->SetValue("dock");
if (!sideStr.empty())
childElem->SetAttribute("side", sideStr.c_str());
save_dock_layout(childElem, subdock);
}
else {
// Set the widget ID as the element name, e.g. <timeline />,
// <colorbar />, etc.
childElem->SetValue(child->id().c_str());
if (!sideStr.empty())
childElem->SetAttribute("side", sideStr.c_str());
if (size.w)
childElem->SetAttribute("width", size.w);
if (size.h)
childElem->SetAttribute("height", size.h);
}
}
}
static void load_dock_layout(const XMLElement* elem, Dock* dock)
{
const char* elemNameStr = elem->Value();
if (!elemNameStr) {
ASSERT(false); // Impossible?
return;
}
const std::string elemName = elemNameStr;
MainWindow* win = App::instance()->mainWindow();
ASSERT(win);
ui::Widget* widget = nullptr;
Dock* subdock = nullptr;
int side = ui::CENTER;
if (const auto* sideStr = elem->Attribute("side")) {
if (std::strcmp(sideStr, "left") == 0)
side = ui::LEFT;
if (std::strcmp(sideStr, "right") == 0)
side = ui::RIGHT;
if (std::strcmp(sideStr, "top") == 0)
side = ui::TOP;
if (std::strcmp(sideStr, "bottom") == 0)
side = ui::BOTTOM;
}
const char* widthStr = elem->Attribute("width");
const char* heightStr = elem->Attribute("height");
gfx::Size size;
if (widthStr)
size.w = base::convert_to<int>(std::string(widthStr));
if (heightStr)
size.h = base::convert_to<int>(std::string(heightStr));
if (elemName == "colorbar") {
widget = win->colorBar();
}
else if (elemName == "contextbar") {
widget = win->getContextBar();
}
else if (elemName == "timeline") {
widget = win->getTimeline();
}
else if (elemName == "toolbar") {
widget = win->toolBar();
}
else if (elemName == "workspace") {
widget = win->getWorkspace();
}
else if (elemName == "statusbar") {
widget = win->statusBar();
}
else if (elemName == "dock") {
subdock = dock->subdock(side);
}
if (subdock) {
const auto* childElem = elem->FirstChildElement();
while (childElem) {
load_dock_layout(childElem, subdock);
childElem = childElem->NextSiblingElement();
}
}
else {
dock->dock(side, widget, size);
}
}
// static
LayoutPtr Layout::MakeFromXmlElement(const XMLElement* layoutElem)
{
const char* name = layoutElem->Attribute("name");
const char* id = layoutElem->Attribute("id");
if (id == nullptr || name == nullptr) {
LOG(WARNING, "Invalid XML layout provided\n");
return nullptr;
}
auto layout = std::make_shared<Layout>();
layout->m_id = id;
layout->m_name = name;
layout->m_elem = layoutElem->DeepClone(&layout->m_dummyDoc)->ToElement();
ASSERT(!layout->m_name.empty() && !layout->m_id.empty());
if (layout->m_elem->ChildElementCount() == 0) // TODO: More error checking here.
return nullptr;
if (layout->m_name.empty() || layout->m_id.empty())
return nullptr;
return layout;
}
// static
LayoutPtr Layout::MakeFromDock(const std::string& id, const std::string& name, const Dock* dock)
{
auto layout = std::make_shared<Layout>();
layout->m_id = id;
layout->m_name = name;
layout->m_elem = layout->m_dummyDoc.NewElement("layout");
layout->m_elem->SetAttribute("id", id.c_str());
layout->m_elem->SetAttribute("name", name.c_str());
save_dock_layout(layout->m_elem, dock);
return layout;
}
bool Layout::matchId(const std::string_view id) const
{
if (m_id == id)
return true;
if ((m_id.empty() && id == kDefault) || (m_id == kDefault && id.empty()))
return true;
return false;
}
bool Layout::loadLayout(Dock* dock) const
{
if (!m_elem)
return false;
XMLElement* elem = m_elem->FirstChildElement();
while (elem) {
load_dock_layout(elem, dock);
elem = elem->NextSiblingElement();
}
return true;
}
bool Layout::isValidName(const std::string_view name)
{
if (name.empty())
return false;
if (name[0] == '_')
return false;
if (name.length() > 128)
return false;
return true;
}
} // namespace app

54
src/app/ui/layout.h Normal file
View File

@ -0,0 +1,54 @@
// Aseprite
// Copyright (C) 2022-2024 Igara Studio S.A.
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifndef APP_UI_LAYOUT_H_INCLUDED
#define APP_UI_LAYOUT_H_INCLUDED
#pragma once
#include <memory>
#include <string>
#include "tinyxml2.h"
namespace app {
class Dock;
class Layout;
using LayoutPtr = std::shared_ptr<Layout>;
class Layout final {
public:
static constexpr const char* kDefault = "_default_";
static constexpr const char* kMirroredDefault = "_mirrored_default_";
static constexpr const char* kDefaultOriginal = "_default_original_";
static constexpr const char* kMirroredDefaultOriginal = "_mirrored_default_original_";
static LayoutPtr MakeFromXmlElement(const tinyxml2::XMLElement* layoutElem);
static LayoutPtr MakeFromDock(const std::string& id, const std::string& name, const Dock* dock);
const std::string& id() const { return m_id; }
const std::string& name() const { return m_name; }
const tinyxml2::XMLElement* xmlElement() const { return m_elem; }
bool matchId(std::string_view id) const;
bool loadLayout(Dock* dock) const;
bool isDefault() const { return m_id == kDefault || m_id == kMirroredDefault; }
// Validates that the given name is short and doesn't begin with a "_" (reserved for _defaults)
static bool isValidName(std::string_view name);
private:
std::string m_id;
std::string m_name;
tinyxml2::XMLDocument m_dummyDoc;
tinyxml2::XMLElement* m_elem = nullptr;
};
} // namespace app
#endif

View File

@ -0,0 +1,570 @@
// Aseprite
// Copyright (C) 2021-2025 Igara Studio S.A.
//
// 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/layout_selector.h"
#include "app/app.h"
#include "app/i18n/strings.h"
#include "app/match_words.h"
#include "app/pref/preferences.h"
#include "app/ui/main_window.h"
#include "app/ui/separator_in_view.h"
#include "app/ui/skin/skin_theme.h"
#include "fmt/printf.h"
#include "ui/alert.h"
#include "ui/app_state.h"
#include "ui/entry.h"
#include "ui/label.h"
#include "ui/listitem.h"
#include "ui/tooltips.h"
#include "ui/window.h"
#include "new_layout.xml.h"
#define ANI_TICKS 2
namespace app {
using namespace app::skin;
using namespace ui;
namespace {
// TODO this combobox is similar to FileSelector::CustomFileNameEntry
// and GotoFrameCommand::TagsEntry
class LayoutsEntry final : public ComboBox {
public:
explicit LayoutsEntry(Layouts& layouts) : m_layouts(layouts)
{
setEditable(true);
getEntryWidget()->Change.connect(&LayoutsEntry::onEntryChange, this);
fill(true);
}
private:
void fill(bool all)
{
deleteAllItems();
const MatchWords match(getEntryWidget()->text());
bool matchAny = false;
for (const auto& layout : m_layouts) {
if (layout->isDefault())
continue; // Ignore custom defaults.
if (match(layout->name())) {
matchAny = true;
break;
}
}
for (const auto& layout : m_layouts) {
if (layout->isDefault())
continue;
if (all || !matchAny || match(layout->name()))
addItem(layout->name());
}
}
void onEntryChange() override
{
closeListBox();
fill(false);
if (getItemCount() > 0 && !empty())
openListBox();
}
Layouts& m_layouts;
};
}; // namespace
class LayoutSelector::LayoutItem final : public ListItem {
public:
enum LayoutOption : uint8_t {
DEFAULT,
MIRRORED_DEFAULT,
USER_DEFINED,
NEW_LAYOUT,
};
LayoutItem(LayoutSelector* selector,
const LayoutOption option,
const std::string& text,
const std::string& layoutId = "")
: ListItem(text)
, m_option(option)
, m_selector(selector)
, m_layoutId(layoutId)
{
m_hbox.setTransparent(true);
addChild(&m_hbox);
auto* filler = new BoxFiller();
filler->setTransparent(true);
m_hbox.addChild(filler);
if (option == USER_DEFINED ||
((option == DEFAULT || option == MIRRORED_DEFAULT) && !layoutId.empty())) {
addActionButton();
}
}
// Separated from the constructor so we can add it on the fly when modifying Default/Mirrored
void addActionButton(const std::string& newLayoutId = "")
{
if (!newLayoutId.empty())
m_layoutId = newLayoutId;
ASSERT(!m_layoutId.empty());
// TODO: Custom icons for each one would be nice here.
m_actionButton = new IconButton(SkinTheme::instance()->parts.iconClose());
const int th = m_actionButton->textHeight();
m_actionButton->setSizeHint(gfx::Size(th, th));
m_actionButton->setTransparent(true);
m_actionButton->InitTheme.connect(
[this] { m_actionButton->setBgColor(gfx::rgba(0, 0, 0, 0)); });
if (m_option == USER_DEFINED) {
m_actionConn = m_actionButton->Click.connect([this] {
MainWindow* win = App::instance()->mainWindow();
win->layoutSelector()->closeComboBox();
const auto alert = Alert::create(Strings::new_layout_deleting_layout());
alert->addLabel(Strings::new_layout_deleting_layout_confirmation(text()), LEFT);
alert->addButton(Strings::general_ok());
alert->addButton(Strings::general_cancel());
if (alert->show() == 1) {
if (m_layoutId == m_selector->activeLayoutId()) {
m_selector->setActiveLayoutId(Layout::kDefault);
App::instance()->mainWindow()->setDefaultLayout();
}
m_selector->removeLayout(m_layoutId);
}
});
}
else {
m_actionConn = m_actionButton->Click.connect([this] {
MainWindow* win = App::instance()->mainWindow();
win->layoutSelector()->closeComboBox();
const auto alert = Alert::create(Strings::new_layout_restoring_layout());
alert->addLabel(
Strings::new_layout_restoring_layout_confirmation(text().substr(0, text().size() - 1)),
LEFT);
alert->addButton(Strings::general_ok());
alert->addButton(Strings::general_cancel());
if (alert->show() == 1) {
if (m_layoutId == Layout::kDefault) {
App::instance()->mainWindow()->setDefaultLayout();
}
else {
App::instance()->mainWindow()->setMirroredDefaultLayout();
}
m_selector->setActiveLayoutId(m_layoutId);
m_selector->removeLayout(m_layoutId);
}
});
}
m_hbox.addChild(m_actionButton);
}
std::string_view getLayoutId() const { return m_layoutId; }
void selectImmediately() const
{
MainWindow* win = App::instance()->mainWindow();
switch (m_option) {
case DEFAULT: {
if (const auto& defaultLayout = win->layoutSelector()->m_layouts.getById(
Layout::kDefault)) {
win->loadUserLayout(defaultLayout.get());
}
else {
win->setDefaultLayout();
}
m_selector->setActiveLayoutId(Layout::kDefault);
break;
}
case MIRRORED_DEFAULT: {
if (const auto& mirroredLayout = win->layoutSelector()->m_layouts.getById(
Layout::kMirroredDefault)) {
win->loadUserLayout(mirroredLayout.get());
}
else {
win->setMirroredDefaultLayout();
}
m_selector->setActiveLayoutId(Layout::kMirroredDefault);
break;
}
case USER_DEFINED: {
const auto selectedLayout = m_selector->m_layouts.getById(m_layoutId);
ASSERT(!m_layoutId.empty());
ASSERT(selectedLayout);
m_selector->setActiveLayoutId(m_layoutId);
win->loadUserLayout(selectedLayout.get());
break;
}
}
}
void selectAfterClose() const
{
if (m_option != NEW_LAYOUT)
return;
//
// Adding a NEW_LAYOUT
//
MainWindow* win = App::instance()->mainWindow();
gen::NewLayout window;
window.name()->Change.connect([&] {
bool valid = Layout::isValidName(window.name()->text()) &&
m_selector->m_layouts.getById(window.name()->text()) == nullptr;
window.ok()->setEnabled(valid);
});
window.openWindowInForeground();
if (window.closer() == window.ok()) {
const auto layout =
Layout::MakeFromDock(window.name()->text(), window.name()->text(), win->customizableDock());
m_selector->addLayout(layout);
m_selector->m_layouts.saveUserLayouts();
m_selector->setActiveLayoutId(layout->id());
win->loadUserLayout(layout.get());
}
else {
// Ensure we go back to having the layout we were at selected.
m_selector->populateComboBox();
}
}
private:
LayoutOption m_option;
LayoutSelector* m_selector = nullptr;
std::string m_layoutId;
HBox m_hbox;
IconButton* m_actionButton = nullptr;
obs::scoped_connection m_actionConn;
};
void LayoutSelector::LayoutComboBox::onChange()
{
ComboBox::onChange();
if (m_lockChange)
return;
if (auto* item = dynamic_cast<LayoutItem*>(getSelectedItem())) {
item->selectImmediately();
m_selected = item;
}
}
void LayoutSelector::LayoutComboBox::onCloseListBox()
{
ComboBox::onCloseListBox();
if (m_lockChange)
return;
if (m_selected) {
m_selected->selectAfterClose();
m_selected = nullptr;
}
}
LayoutSelector::LayoutSelector(TooltipManager* tooltipManager, Widget* notifications)
: m_button(SkinTheme::instance()->parts.iconLayout())
, m_notifications(notifications)
{
setActiveLayoutId(Preferences::instance().general.workspaceLayout());
m_button.Click.connect([this]() { switchSelector(); });
m_comboBox.setVisible(false);
m_top.setExpansive(true);
addChild(&m_top);
addChild(&m_center);
addChild(&m_bottom);
m_center.addChild(&m_comboBox);
m_center.addChild(&m_button);
m_center.addChild(m_notifications);
setupTooltips(tooltipManager);
initTheme();
}
LayoutSelector::~LayoutSelector()
{
m_center.removeChild(m_notifications);
Preferences::instance().general.workspaceLayout(m_activeLayoutId);
if (!is_app_state_closing())
stopAnimation();
}
LayoutPtr LayoutSelector::activeLayout() const
{
return m_layouts.getById(m_activeLayoutId);
}
void LayoutSelector::addLayout(const LayoutPtr& layout)
{
m_layouts.addLayout(layout);
populateComboBox();
}
void LayoutSelector::removeLayout(const LayoutPtr& layout)
{
m_layouts.removeLayout(layout);
m_layouts.saveUserLayouts();
populateComboBox();
}
void LayoutSelector::removeLayout(const std::string& layoutId)
{
auto layout = m_layouts.getById(layoutId);
ASSERT(layout);
removeLayout(layout);
}
void LayoutSelector::updateActiveLayout(const LayoutPtr& newLayout)
{
bool added = m_layouts.addLayout(newLayout);
setActiveLayoutId(newLayout->id());
m_layouts.saveUserLayouts();
if (added && newLayout->isDefault()) {
// Mark it with an asterisk if we're editing a default layout.
populateComboBox();
}
}
void LayoutSelector::onInitTheme(ui::InitThemeEvent& ev)
{
VBox::onInitTheme(ev);
auto* theme = SkinTheme::get(this);
setBgColor(theme->colors.windowFace());
noBorderNoChildSpacing();
m_top.noBorderNoChildSpacing();
m_center.noBorderNoChildSpacing();
m_bottom.noBorderNoChildSpacing();
m_comboBox.noBorderNoChildSpacing();
m_button.noBorderNoChildSpacing();
m_bottom.setStyle(theme->styles.tabBottom());
m_bottom.setMinSize(gfx::Size(0, theme->dimensions.tabsBottomHeight()));
}
void LayoutSelector::onAnimationFrame()
{
switch (animation()) {
case ANI_NONE: break;
case ANI_EXPANDING:
case ANI_COLLAPSING: {
const double t = animationTime();
m_comboBox.setSizeHint(gfx::Size(int(inbetween(m_startSize.w, m_endSize.w, t)),
int(inbetween(m_startSize.h, m_endSize.h, t))));
break;
}
}
if (auto* win = window())
win->layout();
}
void LayoutSelector::onAnimationStop(int animation)
{
switch (animation) {
case ANI_EXPANDING:
m_comboBox.setSizeHint(m_endSize);
if (m_switchComboBoxAfterAni) {
m_switchComboBoxAfterAni = false;
m_comboBox.openListBox();
}
break;
case ANI_COLLAPSING:
m_comboBox.setVisible(false);
m_comboBox.setSizeHint(m_endSize);
if (m_switchComboBoxAfterAni) {
m_switchComboBoxAfterAni = false;
m_comboBox.closeListBox();
}
break;
}
if (auto* win = window())
win->layout();
}
void LayoutSelector::switchSelector()
{
bool expand;
if (!m_comboBox.isVisible()) {
expand = true;
// Create the combobox for first time
if (m_comboBox.getItemCount() == 0) {
populateComboBox();
}
m_comboBox.setVisible(true);
m_comboBox.resetSizeHint();
m_startSize = gfx::Size(0, 0);
m_endSize = m_comboBox.sizeHint();
}
else {
expand = false;
m_startSize = m_comboBox.bounds().size();
m_endSize = gfx::Size(0, 0);
}
if (auto* item = getItemByLayoutId(m_activeLayoutId))
m_comboBox.setSelectedItem(item);
m_comboBox.setSizeHint(m_startSize);
startAnimation((expand ? ANI_EXPANDING : ANI_COLLAPSING), ANI_TICKS);
MainWindow* win = App::instance()->mainWindow();
win->setCustomizeDock(expand);
}
void LayoutSelector::switchSelectorFromCommand()
{
m_switchComboBoxAfterAni = true;
switchSelector();
}
bool LayoutSelector::isSelectorVisible() const
{
return (m_comboBox.isVisible());
}
void LayoutSelector::closeComboBox()
{
m_comboBox.closeListBox();
}
void LayoutSelector::setupTooltips(TooltipManager* tooltipManager)
{
tooltipManager->addTooltipFor(&m_button, Strings::main_window_layout(), TOP);
}
void LayoutSelector::setActiveLayoutId(const std::string& layoutId)
{
if (layoutId.empty()) {
m_activeLayoutId = Layout::kDefault;
return;
}
if (layoutId == m_activeLayoutId)
return;
m_activeLayoutId = layoutId;
for (auto* item : m_comboBox.items()) {
if (auto* layoutItem = dynamic_cast<LayoutItem*>(item)) {
if (layoutItem->getLayoutId() == layoutId) {
m_comboBox.setSelectedItem(item);
break;
}
}
}
}
void LayoutSelector::populateComboBox()
{
// Disable combobox onChange() event processing when we are
// re-creating the combobox items. This avoids calling
// LayoutSelector::LayoutItem::selectImmediately() which could
// delete docks that generate this same event, e.g. resizing a dock
// can generate a UserResizedDock which might call this
// populateComboBox() function.
m_comboBox.setLockChange(true);
// Defer deletion of current items because we can be inside one of
// these item callbacks.
auto itemsCopy = m_comboBox.items();
for (auto* item : itemsCopy) {
m_comboBox.removeItem(item);
item->deferDelete();
}
m_comboBox.addItem(new SeparatorInView(Strings::main_window_layout(), HORIZONTAL));
m_comboBox.addItem(
new LayoutItem(this, LayoutItem::DEFAULT, Strings::main_window_default_layout()));
m_comboBox.addItem(new LayoutItem(this,
LayoutItem::MIRRORED_DEFAULT,
Strings::main_window_mirrored_default_layout()));
m_comboBox.addItem(new SeparatorInView(Strings::main_window_user_layouts(), HORIZONTAL));
for (const auto& layout : m_layouts) {
LayoutItem* item;
if (layout->isDefault()) {
item = dynamic_cast<LayoutItem*>(
m_comboBox.getItem(layout->id() == Layout::kDefault ? 1 : 2));
// Indicate we've modified this with an asterisk.
item->setText(item->text() + "*");
item->addActionButton(layout->id());
}
else {
item = new LayoutItem(this, LayoutItem::USER_DEFINED, layout->name(), layout->id());
m_comboBox.addItem(item);
}
if (layout->id() == m_activeLayoutId)
m_comboBox.setSelectedItem(item);
}
m_comboBox.addItem(
new LayoutItem(this, LayoutItem::NEW_LAYOUT, Strings::main_window_new_layout(), ""));
if (m_activeLayoutId == Layout::kDefault)
m_comboBox.setSelectedItemIndex(1);
if (m_activeLayoutId == Layout::kMirroredDefault)
m_comboBox.setSelectedItemIndex(2);
m_comboBox.getEntryWidget()->deselectText();
m_comboBox.setLockChange(false);
}
LayoutSelector::LayoutItem* LayoutSelector::getItemByLayoutId(const std::string& id)
{
for (auto* child : m_comboBox) {
if (auto* item = dynamic_cast<LayoutItem*>(child)) {
if (item->getLayoutId() == id)
return item;
}
}
return nullptr;
}
} // namespace app

View File

@ -0,0 +1,94 @@
// Aseprite
// Copyright (C) 2021-2025 Igara Studio S.A.
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifndef APP_UI_LAYOUT_SELECTOR_H_INCLUDED
#define APP_UI_LAYOUT_SELECTOR_H_INCLUDED
#pragma once
#include "app/ui/dockable.h"
#include "app/ui/icon_button.h"
#include "app/ui/layout.h"
#include "app/ui/layouts.h"
#include "ui/animated_widget.h"
#include "ui/box.h"
#include "ui/combobox.h"
#include <memory>
#include <vector>
namespace ui {
class TooltipManager;
}
namespace app {
class LayoutSelector : public ui::VBox,
public ui::AnimatedWidget,
public Dockable {
enum Ani : int {
ANI_NONE,
ANI_EXPANDING,
ANI_COLLAPSING,
};
class LayoutItem;
class LayoutComboBox : public ui::ComboBox {
public:
void setLockChange(bool state) { m_lockChange = state; }
private:
void onChange() override;
void onCloseListBox() override;
LayoutItem* m_selected = nullptr;
bool m_lockChange = false;
};
public:
LayoutSelector(ui::TooltipManager* tooltipManager, ui::Widget* notifications);
~LayoutSelector();
LayoutPtr activeLayout() const;
const std::string& activeLayoutId() const { return m_activeLayoutId; }
void addLayout(const LayoutPtr& layout);
void removeLayout(const LayoutPtr& layout);
void removeLayout(const std::string& layoutId);
void updateActiveLayout(const LayoutPtr& layout);
void switchSelector();
void switchSelectorFromCommand();
bool isSelectorVisible() const;
void closeComboBox();
// Dockable impl
int dockableAt() const override { return ui::TOP | ui::BOTTOM; }
protected:
void onInitTheme(ui::InitThemeEvent& ev) override;
private:
void setupTooltips(ui::TooltipManager* tooltipManager);
void setActiveLayoutId(const std::string& layoutId);
void populateComboBox();
LayoutItem* getItemByLayoutId(const std::string& id);
void onAnimationFrame() override;
void onAnimationStop(int animation) override;
std::string m_activeLayoutId;
ui::HBox m_top, m_center, m_bottom;
LayoutComboBox m_comboBox;
IconButton m_button;
Widget* m_notifications = nullptr;
gfx::Size m_startSize;
gfx::Size m_endSize;
Layouts m_layouts;
bool m_switchComboBoxAfterAni = false;
};
} // namespace app
#endif

146
src/app/ui/layouts.cpp Normal file
View File

@ -0,0 +1,146 @@
// Aseprite
// Copyright (c) 2022-2024 Igara Studio S.A.
//
// 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/layouts.h"
#include "app/resource_finder.h"
#include "app/xml_document.h"
#include "app/xml_exception.h"
#include "base/fs.h"
#include <algorithm>
#include <fstream>
namespace app {
using namespace tinyxml2;
Layouts::Layouts()
{
try {
std::string fn = m_userLayoutsFilename = UserLayoutsFilename();
if (base::is_file(fn))
load(fn);
}
catch (const std::exception& ex) {
LOG(ERROR, "LAY: Error loading user layouts: %s\n", ex.what());
}
}
Layouts::~Layouts()
{
try {
saveUserLayouts();
}
catch (const std::exception& ex) {
LOG(ERROR, "LAY: Error saving user layouts on exit: %s\n", ex.what());
}
}
LayoutPtr Layouts::getById(const std::string& id) const
{
auto it = std::find_if(m_layouts.begin(), m_layouts.end(), [&id](const LayoutPtr& l) {
return l->matchId(id);
});
return (it != m_layouts.end() ? *it : nullptr);
}
bool Layouts::addLayout(const LayoutPtr& layout)
{
ASSERT(layout);
const auto it = std::find_if(m_layouts.begin(), m_layouts.end(), [layout](const LayoutPtr& l) {
return l->matchId(layout->id());
});
if (it != m_layouts.end()) {
*it = layout; // Replace existent layout
return false;
}
m_layouts.push_back(layout);
return true;
}
void Layouts::removeLayout(const LayoutPtr& layout)
{
if (m_layouts.size() <= 1) {
m_layouts.clear();
return;
}
ASSERT(layout);
const auto it = std::find_if(m_layouts.begin(), m_layouts.end(), [layout](const LayoutPtr& l) {
return l->matchId(layout->id());
});
m_layouts.erase(it);
}
void Layouts::saveUserLayouts()
{
if (m_userLayoutsFilename.empty())
return;
save(m_userLayoutsFilename);
// TODO: We probably have too much I/O here, but it's the easiest way to keep the XML and
// internal representations synced up.
reload();
}
void Layouts::reload()
{
if (m_userLayoutsFilename.empty())
return;
m_layouts.clear();
load(m_userLayoutsFilename);
}
void Layouts::load(const std::string& fn)
{
const XMLDocumentRef doc = app::open_xml(fn);
XMLHandle handle(doc.get());
XMLElement* layoutElem =
handle.FirstChildElement("layouts").FirstChildElement("layout").ToElement();
while (layoutElem) {
if (auto layout = Layout::MakeFromXmlElement(layoutElem)) {
m_layouts.push_back(layout);
}
layoutElem = layoutElem->NextSiblingElement();
}
}
void Layouts::save(const std::string& fn) const
{
auto doc = std::make_unique<XMLDocument>();
XMLElement* layoutsElem = doc->NewElement("layouts");
for (const auto& layout : m_layouts) {
layoutsElem->InsertEndChild(layout->xmlElement()->DeepClone(doc.get()));
}
doc->InsertEndChild(doc->NewDeclaration(R"(xml version="1.0" encoding="utf-8")"));
doc->InsertEndChild(layoutsElem);
save_xml(doc.get(), fn);
}
// static
std::string Layouts::UserLayoutsFilename()
{
ResourceFinder rf;
rf.includeUserDir("user.aseprite-layouts");
return rf.getFirstOrCreateDefault();
}
} // namespace app

52
src/app/ui/layouts.h Normal file
View File

@ -0,0 +1,52 @@
// Aseprite
// Copyright (c) 2022-2024 Igara Studio S.A.
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifndef APP_UI_LAYOUTS_H_INCLUDED
#define APP_UI_LAYOUTS_H_INCLUDED
#pragma once
#include "app/ui/layout.h"
#include <string>
#include <vector>
namespace app {
class Layouts {
public:
Layouts();
~Layouts();
size_t size() const { return m_layouts.size(); }
LayoutPtr getById(const std::string& id) const;
// Returns true if the layout is added, or false if it was
// replaced.
bool addLayout(const LayoutPtr& layout);
void removeLayout(const LayoutPtr& layout);
void saveUserLayouts();
void reload();
// To iterate layouts
using List = std::vector<LayoutPtr>;
using iterator = List::iterator;
iterator begin() { return m_layouts.begin(); }
iterator end() { return m_layouts.end(); }
private:
void load(const std::string& fn);
void save(const std::string& fn) const;
static std::string UserLayoutsFilename();
List m_layouts;
std::string m_userLayoutsFilename;
};
} // namespace app
#endif

View File

@ -9,18 +9,23 @@
#define APP_UI_MAIN_MENU_BAR_H_INCLUDED #define APP_UI_MAIN_MENU_BAR_H_INCLUDED
#pragma once #pragma once
#include "app/ui/dockable.h"
#include "obs/connection.h" #include "obs/connection.h"
#include "ui/menu.h" #include "ui/menu.h"
namespace app { namespace app {
class MainMenuBar : public ui::MenuBar { class MainMenuBar : public ui::MenuBar,
public Dockable {
public: public:
MainMenuBar(); MainMenuBar();
void queueReload(); void queueReload();
void reload(); void reload();
// Dockable impl
int dockableAt() const override { return ui::TOP | ui::BOTTOM; }
private: private:
obs::scoped_connection m_extKeys; obs::scoped_connection m_extKeys;
obs::scoped_connection m_extScripts; obs::scoped_connection m_extScripts;

View File

@ -1,5 +1,5 @@
// Aseprite // Aseprite
// Copyright (C) 2018-2024 Igara Studio S.A. // Copyright (C) 2018-2025 Igara Studio S.A.
// Copyright (C) 2001-2018 David Capello // Copyright (C) 2001-2018 David Capello
// //
// This program is distributed under the terms of // This program is distributed under the terms of
@ -24,9 +24,11 @@
#include "app/ui/color_bar.h" #include "app/ui/color_bar.h"
#include "app/ui/context_bar.h" #include "app/ui/context_bar.h"
#include "app/ui/doc_view.h" #include "app/ui/doc_view.h"
#include "app/ui/dock.h"
#include "app/ui/editor/editor.h" #include "app/ui/editor/editor.h"
#include "app/ui/editor/editor_view.h" #include "app/ui/editor/editor_view.h"
#include "app/ui/home_view.h" #include "app/ui/home_view.h"
#include "app/ui/layout_selector.h"
#include "app/ui/main_menu_bar.h" #include "app/ui/main_menu_bar.h"
#include "app/ui/notifications.h" #include "app/ui/notifications.h"
#include "app/ui/preview_editor.h" #include "app/ui/preview_editor.h"
@ -42,6 +44,7 @@
#include "os/event.h" #include "os/event.h"
#include "os/event_queue.h" #include "os/event_queue.h"
#include "os/system.h" #include "os/system.h"
#include "ui/app_state.h"
#include "ui/drag_event.h" #include "ui/drag_event.h"
#include "ui/message.h" #include "ui/message.h"
#include "ui/splitter.h" #include "ui/splitter.h"
@ -57,6 +60,10 @@ namespace app {
using namespace ui; using namespace ui;
static constexpr const char* kLegacyLayoutMainWindowSection = "layout:main_window";
static constexpr const char* kLegacyLayoutTimelineSplitter = "timeline_splitter";
static constexpr const char* kLegacyLayoutColorBarSplitter = "color_bar_splitter";
class ScreenScalePanic : public INotificationDelegate { class ScreenScalePanic : public INotificationDelegate {
public: public:
std::string notificationText() override { return "Reset Scale!"; } std::string notificationText() override { return "Reset Scale!"; }
@ -83,7 +90,11 @@ public:
}; };
MainWindow::MainWindow() MainWindow::MainWindow()
: m_mode(NormalMode) : ui::Window(ui::Window::DesktopWindow)
, m_tooltipManager(new TooltipManager)
, m_dock(new Dock)
, m_customizableDock(new Dock)
, m_mode(NormalMode)
, m_homeView(nullptr) , m_homeView(nullptr)
, m_scalePanic(nullptr) , m_scalePanic(nullptr)
, m_browserView(nullptr) , m_browserView(nullptr)
@ -105,8 +116,9 @@ MainWindow::MainWindow()
// Refer to https://github.com/aseprite/aseprite/issues/3914 // Refer to https://github.com/aseprite/aseprite/issues/3914
void MainWindow::initialize() void MainWindow::initialize()
{ {
m_tooltipManager = new TooltipManager(); m_menuBar = std::make_unique<MainMenuBar>();
m_menuBar = new MainMenuBar(); m_notifications = std::make_unique<Notifications>();
m_layoutSelector = std::make_unique<LayoutSelector>(m_tooltipManager, m_notifications.get());
// Register commands to load menus+shortcuts for these commands // Register commands to load menus+shortcuts for these commands
Editor::registerCommands(); Editor::registerCommands();
@ -117,20 +129,19 @@ void MainWindow::initialize()
// Setup the main menubar // Setup the main menubar
m_menuBar->setMenu(AppMenus::instance()->getRootMenu()); m_menuBar->setMenu(AppMenus::instance()->getRootMenu());
m_notifications = new Notifications(); m_statusBar = std::make_unique<StatusBar>(m_tooltipManager);
m_statusBar = new StatusBar(m_tooltipManager); m_toolBar = std::make_unique<ToolBar>();
m_toolBar = new ToolBar(); m_tabsBar = std::make_unique<WorkspaceTabs>(this);
m_tabsBar = new WorkspaceTabs(this); m_workspace = std::make_unique<Workspace>();
m_workspace = new Workspace(); m_previewEditor = std::make_unique<PreviewEditorWindow>();
m_previewEditor = new PreviewEditorWindow(); m_colorBar = std::make_unique<ColorBar>(m_tooltipManager);
m_colorBar = new ColorBar(colorBarPlaceholder()->align(), m_tooltipManager); m_contextBar = std::make_unique<ContextBar>(m_tooltipManager, m_colorBar.get());
m_contextBar = new ContextBar(m_tooltipManager, m_colorBar);
// The timeline (AniControls) tooltips will use the keyboard // The timeline (AniControls) tooltips will use the keyboard
// shortcuts loaded above. // shortcuts loaded above.
m_timeline = new Timeline(m_tooltipManager); m_timeline = std::make_unique<Timeline>(m_tooltipManager);
m_workspace->setTabsBar(m_tabsBar); m_workspace->setTabsBar(m_tabsBar.get());
m_workspace->BeforeViewChanged.connect(&MainWindow::onBeforeViewChange, this); m_workspace->BeforeViewChanged.connect(&MainWindow::onBeforeViewChange, this);
m_workspace->ActiveViewChanged.connect(&MainWindow::onActiveViewChange, this); m_workspace->ActiveViewChanged.connect(&MainWindow::onActiveViewChange, this);
@ -146,21 +157,31 @@ void MainWindow::initialize()
m_workspace->setExpansive(true); m_workspace->setExpansive(true);
m_notifications->setVisible(false); m_notifications->setVisible(false);
// IDs to create UI layouts from a Dock (see app::Layout
// constructor).
m_colorBar->setId("colorbar");
m_contextBar->setId("contextbar");
m_statusBar->setId("statusbar");
m_timeline->setId("timeline");
m_toolBar->setId("toolbar");
m_workspace->setId("workspace");
// Add the widgets in the boxes // Add the widgets in the boxes
addChild(m_tooltipManager); addChild(m_tooltipManager);
menuBarPlaceholder()->addChild(m_menuBar); addChild(m_dock);
menuBarPlaceholder()->addChild(m_notifications);
contextBarPlaceholder()->addChild(m_contextBar);
colorBarPlaceholder()->addChild(m_colorBar);
toolBarPlaceholder()->addChild(m_toolBar);
statusBarPlaceholder()->addChild(m_statusBar);
tabsPlaceholder()->addChild(m_tabsBar);
workspacePlaceholder()->addChild(m_workspace);
timelinePlaceholder()->addChild(m_timeline);
// Default splitter positions m_customizableDockPlaceholder = std::make_unique<Widget>();
colorBarSplitter()->setPosition(m_colorBar->sizeHint().w); m_customizableDockPlaceholder->addChild(m_customizableDock);
timelineSplitter()->setPosition(75);
m_dock->top()->dock(ui::RIGHT, m_layoutSelector.get());
m_dock->top()->center()->dock(ui::BOTTOM, m_tabsBar.get());
m_dock->top()->center()->dock(ui::CENTER, m_menuBar.get());
m_dock->dock(ui::CENTER, m_customizableDockPlaceholder.get());
// After the user resizes the dock we save the updated layout
m_saveDockLayoutConn = m_customizableDock->UserResizedDock.connect(&MainWindow::saveActiveLayout,
this);
// Reconfigure workspace when the timeline position is changed. // Reconfigure workspace when the timeline position is changed.
auto& pref = Preferences::instance(); auto& pref = Preferences::instance();
@ -172,49 +193,60 @@ void MainWindow::initialize()
AppMenus::instance()->rebuildRecentList(); AppMenus::instance()->rebuildRecentList();
// When the language is change, we reload the menu bar strings and // When the language is changed, we reload the menu bar strings and
// relayout the whole main window. // relayout the whole main window.
Strings::instance()->LanguageChange.connect([this] { onLanguageChange(); }); Strings::instance()->LanguageChange.connect([this] { onLanguageChange(); });
initTheme();
} }
MainWindow::~MainWindow() MainWindow::~MainWindow()
{ {
delete m_scalePanic; m_timelineResizeConn.disconnect();
m_colorBarResizeConn.disconnect();
m_saveDockLayoutConn.disconnect();
m_dock->resetDocks();
m_customizableDock->resetDocks();
// Leaving them in can cause crashes when cleaning up.
m_dock = nullptr;
m_customizableDock = nullptr;
m_layoutSelector.reset();
m_scalePanic.reset();
#ifdef ENABLE_SCRIPTING #ifdef ENABLE_SCRIPTING
if (m_devConsoleView) { if (m_devConsoleView) {
if (m_devConsoleView->parent() && m_workspace) if (m_devConsoleView->parent() && m_workspace)
m_workspace->removeView(m_devConsoleView); m_workspace->removeView(m_devConsoleView.get());
delete m_devConsoleView; m_devConsoleView.reset();
} }
#endif #endif
if (m_browserView) { if (m_browserView) {
if (m_browserView->parent() && m_workspace) if (m_browserView->parent() && m_workspace)
m_workspace->removeView(m_browserView); m_workspace->removeView(m_browserView.get());
delete m_browserView; m_browserView.reset();
} }
if (m_homeView) { if (m_homeView) {
if (m_homeView->parent() && m_workspace) if (m_homeView->parent() && m_workspace)
m_workspace->removeView(m_homeView); m_workspace->removeView(m_homeView.get());
delete m_homeView; m_homeView.reset();
} }
if (m_contextBar) m_contextBar.reset();
delete m_contextBar; m_previewEditor.reset();
if (m_previewEditor)
delete m_previewEditor;
// Destroy the workspace first so ~Editor can dettach slots from // Destroy the workspace first so ~Editor can dettach slots from
// ColorBar. TODO this is a terrible hack for slot/signal stuff, // ColorBar. TODO this is a terrible hack for slot/signal stuff,
// connections should be handle in a better/safer way. // connections should be handle in a better/safer way.
if (m_workspace) m_workspace.reset();
delete m_workspace;
// Remove the root-menu from the menu-bar (because the rootmenu // Remove the root-menu from the menu-bar (because the rootmenu
// module should destroy it). // module should destroy it).
if (m_menuBar) if (m_menuBar)
m_menuBar->setMenu(NULL); m_menuBar->setMenu(nullptr);
} }
void MainWindow::onLanguageChange() void MainWindow::onLanguageChange()
@ -232,8 +264,8 @@ DocView* MainWindow::getDocView()
HomeView* MainWindow::getHomeView() HomeView* MainWindow::getHomeView()
{ {
if (!m_homeView) if (!m_homeView)
m_homeView = new HomeView; m_homeView = std::make_unique<HomeView>();
return m_homeView; return m_homeView.get();
} }
#ifdef ENABLE_UPDATER #ifdef ENABLE_UPDATER
@ -254,7 +286,7 @@ void MainWindow::showNotification(INotificationDelegate* del)
{ {
m_notifications->addLink(del); m_notifications->addLink(del);
m_notifications->setVisible(true); m_notifications->setVisible(true);
m_notifications->parent()->layout(); layout();
} }
void MainWindow::showHomeOnOpen() void MainWindow::showHomeOnOpen()
@ -270,20 +302,20 @@ void MainWindow::showHomeOnOpen()
// Show "Home" tab in the first position, and select it only if // Show "Home" tab in the first position, and select it only if
// there is no other view selected. // there is no other view selected.
m_workspace->addView(m_homeView, 0); m_workspace->addView(m_homeView.get(), 0);
if (selectedTab) if (selectedTab)
m_tabsBar->selectTab(selectedTab); m_tabsBar->selectTab(selectedTab);
else else
m_tabsBar->selectTab(m_homeView); m_tabsBar->selectTab(m_homeView.get());
} }
} }
void MainWindow::showHome() void MainWindow::showHome()
{ {
if (!getHomeView()->parent()) { if (!getHomeView()->parent()) {
m_workspace->addView(m_homeView, 0); m_workspace->addView(m_homeView.get(), 0);
} }
m_tabsBar->selectTab(m_homeView); m_tabsBar->selectTab(m_homeView.get());
} }
void MainWindow::showDefaultStatusBar() void MainWindow::showDefaultStatusBar()
@ -298,19 +330,19 @@ void MainWindow::showDefaultStatusBar()
bool MainWindow::isHomeSelected() const bool MainWindow::isHomeSelected() const
{ {
return (m_homeView && m_workspace->activeView() == m_homeView); return (m_homeView && m_workspace->activeView() == m_homeView.get());
} }
void MainWindow::showBrowser(const std::string& filename, const std::string& section) void MainWindow::showBrowser(const std::string& filename, const std::string& section)
{ {
if (!m_browserView) if (!m_browserView)
m_browserView = new BrowserView; m_browserView = std::make_unique<BrowserView>();
m_browserView->loadFile(filename, section); m_browserView->loadFile(filename, section);
if (!m_browserView->parent()) { if (!m_browserView->parent()) {
m_workspace->addView(m_browserView); m_workspace->addView(m_browserView.get());
m_tabsBar->selectTab(m_browserView); m_tabsBar->selectTab(m_browserView.get());
} }
} }
@ -318,11 +350,11 @@ void MainWindow::showDevConsole()
{ {
#ifdef ENABLE_SCRIPTING #ifdef ENABLE_SCRIPTING
if (!m_devConsoleView) if (!m_devConsoleView)
m_devConsoleView = new DevConsoleView; m_devConsoleView = std::make_unique<DevConsoleView>();
if (!m_devConsoleView->parent()) { if (!m_devConsoleView->parent()) {
m_workspace->addView(m_devConsoleView); m_workspace->addView(m_devConsoleView.get());
m_tabsBar->selectTab(m_devConsoleView); m_tabsBar->selectTab(m_devConsoleView.get());
} }
#endif #endif
} }
@ -351,15 +383,109 @@ void MainWindow::setTimelineVisibility(bool visible)
void MainWindow::popTimeline() void MainWindow::popTimeline()
{ {
Preferences& preferences = Preferences::instance(); if (!Preferences::instance().general.autoshowTimeline())
if (!preferences.general.autoshowTimeline())
return; return;
if (!getTimelineVisibility()) if (!getTimelineVisibility())
setTimelineVisibility(true); setTimelineVisibility(true);
} }
void MainWindow::setDefaultLayout()
{
m_timelineResizeConn.disconnect();
m_colorBarResizeConn.disconnect();
const auto colorBarWidth = get_config_double(kLegacyLayoutMainWindowSection,
kLegacyLayoutColorBarSplitter,
m_colorBar->sizeHint().w);
m_customizableDock->resetDocks();
m_customizableDock->dock(ui::LEFT, m_colorBar.get(), gfx::Size(colorBarWidth, 0));
m_customizableDock->dock(ui::BOTTOM, m_statusBar.get());
m_customizableDock->center()->dock(ui::TOP, m_contextBar.get());
m_customizableDock->center()->dock(ui::RIGHT, m_toolBar.get());
const auto timelineSplitterPos =
get_config_double(kLegacyLayoutMainWindowSection, kLegacyLayoutTimelineSplitter, 75.0) / 100.0;
const auto timelinePos = Preferences::instance().general.timelinePosition();
// We calculate a estimate of the workspace bounds (as we don't yet
// know its size, because we're just constructing the dock where the
// workspace will be inside).
const int kLegacySplitterSeparation = 3 * ui::guiscale();
auto workspaceBounds = bounds();
workspaceBounds.w -= colorBarWidth + m_toolBar->sizeHint().w + 2 * kLegacySplitterSeparation;
workspaceBounds.h -= m_menuBar->sizeHint().h + m_tabsBar->sizeHint().h +
m_contextBar->sizeHint().h + m_statusBar->sizeHint().h;
int timelineSide;
gfx::Size timelineSize(75, 75);
switch (timelinePos) {
case gen::TimelinePosition::LEFT:
timelineSide = ui::LEFT;
timelineSize.w = (workspaceBounds.w * (1.0 - timelineSplitterPos));
break;
case gen::TimelinePosition::RIGHT:
timelineSide = ui::RIGHT;
timelineSize.w = (workspaceBounds.w * (1.0 - timelineSplitterPos));
break;
default:
case gen::TimelinePosition::BOTTOM:
timelineSide = ui::BOTTOM;
timelineSize.h = (workspaceBounds.h * (1.0 - timelineSplitterPos));
break;
}
// Timeline config
m_customizableDock->center()->center()->dock(timelineSide,
m_timeline.get(),
timelineSize.createUnion(gfx::Size(64, 64)));
m_customizableDock->center()->center()->dock(ui::CENTER, m_workspace.get());
configureWorkspaceLayout();
}
void MainWindow::setMirroredDefaultLayout()
{
m_timelineResizeConn.disconnect();
m_colorBarResizeConn.disconnect();
auto colorBarWidth = get_config_double(kLegacyLayoutMainWindowSection,
kLegacyLayoutColorBarSplitter,
m_colorBar->sizeHint().w);
m_customizableDock->resetDocks();
m_customizableDock->dock(ui::RIGHT, m_colorBar.get(), gfx::Size(colorBarWidth, 0));
m_customizableDock->dock(ui::BOTTOM, m_statusBar.get());
m_customizableDock->center()->dock(ui::TOP, m_contextBar.get());
m_customizableDock->center()->dock(ui::LEFT, m_toolBar.get());
m_customizableDock->center()->center()->dock(ui::BOTTOM,
m_timeline.get(),
gfx::Size(64 * guiscale(), 64 * guiscale()));
m_customizableDock->center()->center()->dock(ui::CENTER, m_workspace.get());
configureWorkspaceLayout();
}
void MainWindow::loadUserLayout(const Layout* layout)
{
m_timelineResizeConn.disconnect();
m_colorBarResizeConn.disconnect();
m_customizableDock->resetDocks();
if (!layout->loadLayout(m_customizableDock)) {
LOG(WARNING, "Layout %s failed to load, resetting to default.\n", layout->id().c_str());
setDefaultLayout();
}
this->layout();
}
void MainWindow::setCustomizeDock(bool enable)
{
m_customizableDock->setCustomizing(enable);
}
void MainWindow::dataRecoverySessionsAreReady() void MainWindow::dataRecoverySessionsAreReady()
{ {
getHomeView()->dataRecoverySessionsAreReady(); getHomeView()->dataRecoverySessionsAreReady();
@ -375,24 +501,37 @@ bool MainWindow::onProcessMessage(ui::Message* msg)
void MainWindow::onInitTheme(ui::InitThemeEvent& ev) void MainWindow::onInitTheme(ui::InitThemeEvent& ev)
{ {
app::gen::MainWindow::onInitTheme(ev); ui::Window::onInitTheme(ev);
noBorderNoChildSpacing();
if (m_previewEditor) if (m_previewEditor)
m_previewEditor->initTheme(); m_previewEditor->initTheme();
}
void MainWindow::onSaveLayout(SaveLayoutEvent& ev) auto* theme = static_cast<skin::SkinTheme*>(this->theme());
{ m_dock->setBgColor(theme->colors.windowFace());
// Invert the timeline splitter position before we save the setting. m_customizableDock->setBgColor(theme->colors.workspace());
if (Preferences::instance().general.timelinePosition() == gen::TimelinePosition::LEFT) {
timelineSplitter()->setPosition(100 - timelineSplitter()->getPosition());
}
Window::onSaveLayout(ev);
} }
void MainWindow::onResize(ui::ResizeEvent& ev) void MainWindow::onResize(ui::ResizeEvent& ev)
{ {
app::gen::MainWindow::onResize(ev); ui::Window::onResize(ev);
// Load default or user-selected layout after the first resize event
// is received.
if (m_firstResize) {
m_firstResize = false;
// If the layout is defined in the user layouts file, we loaded it
// (it can be a modified default/mirrored layout).
if (LayoutPtr layout = m_layoutSelector->activeLayout()) {
loadUserLayout(layout.get());
}
else if (m_layoutSelector->activeLayoutId() == Layout::kMirroredDefault) {
setMirroredDefaultLayout();
}
else {
setDefaultLayout();
}
}
os::Window* nativeWindow = (display() ? display()->nativeWindow() : nullptr); os::Window* nativeWindow = (display() ? display()->nativeWindow() : nullptr);
if (nativeWindow && nativeWindow->screen()) { if (nativeWindow && nativeWindow->screen()) {
@ -405,7 +544,8 @@ void MainWindow::onResize(ui::ResizeEvent& ev)
if ((scale > 2) && (!m_scalePanic)) { if ((scale > 2) && (!m_scalePanic)) {
const gfx::Size wa = nativeWindow->screen()->workarea().size(); const gfx::Size wa = nativeWindow->screen()->workarea().size();
if ((wa.w / scale < 256 || wa.h / scale < 256)) { if ((wa.w / scale < 256 || wa.h / scale < 256)) {
showNotification(m_scalePanic = new ScreenScalePanic); m_scalePanic = std::make_unique<ScreenScalePanic>();
showNotification(m_scalePanic.get());
} }
} }
} }
@ -421,6 +561,11 @@ void MainWindow::onBeforeViewChange()
// inform to the UIContext that the current view has changed. // inform to the UIContext that the current view has changed.
void MainWindow::onActiveViewChange() void MainWindow::onActiveViewChange()
{ {
// If we are closing the app, we just ignore all view changes (as
// docs will be destroyed and views closed).
if (get_app_state() != AppState::kNormal || !m_dock)
return;
// First we have to configure the MainWindow layout (e.g. show // First we have to configure the MainWindow layout (e.g. show
// Timeline if needed) as UIContext::setActiveView() will configure // Timeline if needed) as UIContext::setActiveView() will configure
// several widgets (calling updateUsingEditor() functions) using the // several widgets (calling updateUsingEditor() functions) using the
@ -508,7 +653,7 @@ void MainWindow::onContextMenuTab(Tabs* tabs, TabView* tabView)
WorkspaceView* view = dynamic_cast<WorkspaceView*>(tabView); WorkspaceView* view = dynamic_cast<WorkspaceView*>(tabView);
ASSERT(view); ASSERT(view);
if (view) if (view)
view->onTabPopup(m_workspace); view->onTabPopup(m_workspace.get());
} }
void MainWindow::onTabsContainerDoubleClicked(Tabs* tabs) void MainWindow::onTabsContainerDoubleClicked(Tabs* tabs)
@ -588,71 +733,80 @@ DropTabResult MainWindow::onDropTab(Tabs* tabs,
void MainWindow::configureWorkspaceLayout() void MainWindow::configureWorkspaceLayout()
{ {
// First layout to get the bounds of some widgets
layout();
const auto& pref = Preferences::instance(); const auto& pref = Preferences::instance();
bool normal = (m_mode == NormalMode); bool normal = (m_mode == NormalMode);
bool showMenu = normal;
bool isDoc = (getDocView() != nullptr); bool isDoc = (getDocView() != nullptr);
if (os::System::instance()->menus() == nullptr || pref.general.showMenuBar()) { if (os::System::instance()->menus() && !pref.general.showMenuBar())
m_menuBar->resetMaxSize(); showMenu = false;
}
else {
m_menuBar->setMaxSize(gfx::Size(0, 0));
}
m_menuBar->setVisible(normal); m_menuBar->setVisible(showMenu);
m_notifications->setVisible(normal && m_notifications->hasNotifications()); m_notifications->setVisible(normal && m_notifications->hasNotifications());
m_tabsBar->setVisible(normal); m_tabsBar->setVisible(normal);
colorBarPlaceholder()->setVisible(normal && isDoc); m_colorBar->setVisible(normal && isDoc);
m_colorBarResizeConn = m_customizableDock->Resize.connect(&MainWindow::saveColorBarConfiguration,
this);
m_toolBar->setVisible(normal && isDoc); m_toolBar->setVisible(normal && isDoc);
m_statusBar->setVisible(normal); m_statusBar->setVisible(normal);
m_contextBar->setVisible(isDoc && (m_mode == NormalMode || m_mode == ContextBarAndTimelineMode)); m_contextBar->setVisible(isDoc && (m_mode == NormalMode || m_mode == ContextBarAndTimelineMode));
// Configure timeline // Configure timeline
{ if (m_timeline && m_timeline->parent()) {
auto timelinePosition = pref.general.timelinePosition(); m_timelineResizeConn = dynamic_cast<Dock*>(m_timeline->parent())
bool invertWidgets = false; ->Resize.connect(&MainWindow::saveTimelineConfiguration, this);
int align = VERTICAL;
switch (timelinePosition) {
case gen::TimelinePosition::LEFT:
align = HORIZONTAL;
invertWidgets = true;
break;
case gen::TimelinePosition::RIGHT: align = HORIZONTAL; break;
case gen::TimelinePosition::BOTTOM: break;
}
timelineSplitter()->setAlign(align);
timelinePlaceholder()->setVisible(
isDoc && (m_mode == NormalMode || m_mode == ContextBarAndTimelineMode) &&
pref.general.visibleTimeline());
bool invertSplitterPos = false;
if (invertWidgets) {
if (timelineSplitter()->firstChild() == workspacePlaceholder() &&
timelineSplitter()->lastChild() == timelinePlaceholder()) {
timelineSplitter()->removeChild(workspacePlaceholder());
timelineSplitter()->addChild(workspacePlaceholder());
invertSplitterPos = true;
}
}
else {
if (timelineSplitter()->firstChild() == timelinePlaceholder() &&
timelineSplitter()->lastChild() == workspacePlaceholder()) {
timelineSplitter()->removeChild(timelinePlaceholder());
timelineSplitter()->addChild(timelinePlaceholder());
invertSplitterPos = true;
}
}
if (invertSplitterPos)
timelineSplitter()->setPosition(100 - timelineSplitter()->getPosition());
} }
m_timeline->setVisible(isDoc && (m_mode == NormalMode || m_mode == ContextBarAndTimelineMode) &&
pref.general.visibleTimeline());
if (m_contextBar->isVisible()) { if (m_contextBar->isVisible()) {
m_contextBar->updateForActiveTool(); m_contextBar->updateForActiveTool();
} }
layout(); layout();
invalidate(); }
void MainWindow::saveTimelineConfiguration()
{
const auto& pref = Preferences::instance();
const gfx::Rect timelineBounds = m_timeline->bounds();
const gfx::Rect workspaceBounds = m_customizableDock->center()->center()->bounds();
auto timelinePosition = pref.general.timelinePosition();
double timelineSplitterPos = 0.75;
switch (timelinePosition) {
case gen::TimelinePosition::LEFT:
case gen::TimelinePosition::RIGHT:
timelineSplitterPos = 1.0 - double(timelineBounds.w) / workspaceBounds.w;
break;
case gen::TimelinePosition::BOTTOM:
timelineSplitterPos = 1.0 - double(timelineBounds.h) / workspaceBounds.h;
break;
}
set_config_double(kLegacyLayoutMainWindowSection,
kLegacyLayoutTimelineSplitter,
std::clamp(timelineSplitterPos * 100.0, 1.0, 99.0));
}
void MainWindow::saveColorBarConfiguration()
{
set_config_double(kLegacyLayoutMainWindowSection,
kLegacyLayoutColorBarSplitter,
m_colorBar->bounds().w);
}
void MainWindow::saveActiveLayout()
{
ASSERT(m_layoutSelector);
auto id = m_layoutSelector->activeLayoutId();
auto layout = Layout::MakeFromDock(id, id, m_customizableDock);
m_layoutSelector->updateActiveLayout(layout);
} }
} // namespace app } // namespace app

View File

@ -1,5 +1,5 @@
// Aseprite // Aseprite
// Copyright (C) 2018-2023 Igara Studio S.A. // Copyright (C) 2018-2025 Igara Studio S.A.
// Copyright (C) 2001-2018 David Capello // Copyright (C) 2001-2018 David Capello
// //
// This program is distributed under the terms of // This program is distributed under the terms of
@ -10,9 +10,10 @@
#pragma once #pragma once
#include "app/ui/tabs.h" #include "app/ui/tabs.h"
#include "obs/connection.h"
#include "ui/window.h" #include "ui/window.h"
#include "main_window.xml.h" #include <memory>
namespace ui { namespace ui {
class Splitter; class Splitter;
@ -30,13 +31,17 @@ class ColorBar;
class ContextBar; class ContextBar;
class DevConsoleView; class DevConsoleView;
class DocView; class DocView;
class Dock;
class HomeView; class HomeView;
class INotificationDelegate; class INotificationDelegate;
class Layout;
class LayoutSelector;
class MainMenuBar; class MainMenuBar;
class Notifications; class Notifications;
class PreviewEditorWindow; class PreviewEditorWindow;
class StatusBar; class StatusBar;
class Timeline; class Timeline;
class ToolBar;
class Workspace; class Workspace;
class WorkspaceTabs; class WorkspaceTabs;
@ -44,7 +49,7 @@ namespace crash {
class DataRecovery; class DataRecovery;
} }
class MainWindow : public app::gen::MainWindow, class MainWindow : public ui::Window,
public TabsDelegate { public TabsDelegate {
public: public:
enum Mode { NormalMode, ContextBarAndTimelineMode, EditorOnlyMode }; enum Mode { NormalMode, ContextBarAndTimelineMode, EditorOnlyMode };
@ -52,13 +57,17 @@ public:
MainWindow(); MainWindow();
~MainWindow(); ~MainWindow();
MainMenuBar* getMenuBar() { return m_menuBar; } // TODO refactor: remove the get prefix from these functions
ContextBar* getContextBar() { return m_contextBar; } MainMenuBar* getMenuBar() { return m_menuBar.get(); }
StatusBar* statusBar() { return m_statusBar; } LayoutSelector* layoutSelector() { return m_layoutSelector.get(); }
WorkspaceTabs* getTabsBar() { return m_tabsBar; } ContextBar* getContextBar() { return m_contextBar.get(); }
Timeline* getTimeline() { return m_timeline; } StatusBar* statusBar() { return m_statusBar.get(); }
Workspace* getWorkspace() { return m_workspace; } WorkspaceTabs* getTabsBar() { return m_tabsBar.get(); }
PreviewEditorWindow* getPreviewEditor() { return m_previewEditor; } Timeline* getTimeline() { return m_timeline.get(); }
Workspace* getWorkspace() { return m_workspace.get(); }
ColorBar* colorBar() { return m_colorBar.get(); }
ToolBar* toolBar() { return m_toolBar.get(); }
PreviewEditorWindow* getPreviewEditor() { return m_previewEditor.get(); }
#ifdef ENABLE_UPDATER #ifdef ENABLE_UPDATER
CheckUpdateDelegate* getCheckUpdateDelegate(); CheckUpdateDelegate* getCheckUpdateDelegate();
#endif #endif
@ -83,6 +92,12 @@ public:
void setTimelineVisibility(bool visible); void setTimelineVisibility(bool visible);
void popTimeline(); void popTimeline();
void setDefaultLayout();
void setMirroredDefaultLayout();
void loadUserLayout(const Layout* layout);
Dock* customizableDock() { return m_customizableDock; }
void setCustomizeDock(bool enable);
// When crash::DataRecovery finish to search for sessions, this // When crash::DataRecovery finish to search for sessions, this
// function is called. // function is called.
void dataRecoverySessionsAreReady(); void dataRecoverySessionsAreReady();
@ -109,7 +124,6 @@ public:
protected: protected:
bool onProcessMessage(ui::Message* msg) override; bool onProcessMessage(ui::Message* msg) override;
void onInitTheme(ui::InitThemeEvent& ev) override; void onInitTheme(ui::InitThemeEvent& ev) override;
void onSaveLayout(ui::SaveLayoutEvent& ev) override;
void onResize(ui::ResizeEvent& ev) override; void onResize(ui::ResizeEvent& ev) override;
void onBeforeViewChange(); void onBeforeViewChange();
void onActiveViewChange(); void onActiveViewChange();
@ -121,25 +135,36 @@ private:
DocView* getDocView(); DocView* getDocView();
HomeView* getHomeView(); HomeView* getHomeView();
void configureWorkspaceLayout(); void configureWorkspaceLayout();
void saveTimelineConfiguration();
void saveColorBarConfiguration();
void saveActiveLayout();
ui::TooltipManager* m_tooltipManager; ui::TooltipManager* m_tooltipManager;
MainMenuBar* m_menuBar; Dock* m_dock;
StatusBar* m_statusBar; Dock* m_customizableDock;
ColorBar* m_colorBar; std::unique_ptr<Widget> m_customizableDockPlaceholder;
ContextBar* m_contextBar; std::unique_ptr<MainMenuBar> m_menuBar;
ui::Widget* m_toolBar; std::unique_ptr<Notifications> m_notifications;
WorkspaceTabs* m_tabsBar; std::unique_ptr<LayoutSelector> m_layoutSelector;
std::unique_ptr<StatusBar> m_statusBar;
std::unique_ptr<ColorBar> m_colorBar;
std::unique_ptr<ContextBar> m_contextBar;
std::unique_ptr<ToolBar> m_toolBar;
std::unique_ptr<WorkspaceTabs> m_tabsBar;
Mode m_mode; Mode m_mode;
Timeline* m_timeline; std::unique_ptr<Timeline> m_timeline;
Workspace* m_workspace; std::unique_ptr<Workspace> m_workspace;
PreviewEditorWindow* m_previewEditor; std::unique_ptr<PreviewEditorWindow> m_previewEditor;
HomeView* m_homeView; std::unique_ptr<HomeView> m_homeView;
Notifications* m_notifications; std::unique_ptr<INotificationDelegate> m_scalePanic;
INotificationDelegate* m_scalePanic; std::unique_ptr<BrowserView> m_browserView;
BrowserView* m_browserView;
#ifdef ENABLE_SCRIPTING #ifdef ENABLE_SCRIPTING
DevConsoleView* m_devConsoleView; std::unique_ptr<DevConsoleView> m_devConsoleView;
#endif #endif
obs::scoped_connection m_timelineResizeConn;
obs::scoped_connection m_colorBarResizeConn;
obs::scoped_connection m_saveDockLayoutConn;
bool m_firstResize = true;
}; };
} // namespace app } // namespace app

View File

@ -1,5 +1,5 @@
// Aseprite // Aseprite
// Copyright (C) 2020-2023 Igara Studio S.A. // Copyright (C) 2020-2025 Igara Studio S.A.
// Copyright (C) 2001-2017 David Capello // Copyright (C) 2001-2017 David Capello
// //
// This program is distributed under the terms of // This program is distributed under the terms of
@ -20,6 +20,7 @@
namespace app { namespace app {
using namespace app::skin;
using namespace ui; using namespace ui;
class NotificationItem : public MenuItem { class NotificationItem : public MenuItem {
@ -39,10 +40,7 @@ private:
INotificationDelegate* m_delegate; INotificationDelegate* m_delegate;
}; };
Notifications::Notifications() Notifications::Notifications() : Button(""), m_red(false)
: Button("")
, m_flagStyle(skin::SkinTheme::get(this)->styles.flag())
, m_red(false)
{ {
} }
@ -52,13 +50,22 @@ void Notifications::addLink(INotificationDelegate* del)
m_red = true; m_red = true;
} }
void Notifications::onInitTheme(InitThemeEvent& ev)
{
Button::onInitTheme(ev);
m_popup.initTheme();
}
void Notifications::onSizeHint(SizeHintEvent& ev) void Notifications::onSizeHint(SizeHintEvent& ev)
{ {
ev.setSizeHint(gfx::Size(16, 10) * guiscale()); // TODO hard-coded flag size auto* theme = SkinTheme::get(this);
auto hint = theme->calcSizeHint(this, theme->styles.flag());
ev.setSizeHint(hint);
} }
void Notifications::onPaint(PaintEvent& ev) void Notifications::onPaint(PaintEvent& ev)
{ {
auto* theme = SkinTheme::get(this);
Graphics* g = ev.graphics(); Graphics* g = ev.graphics();
PaintWidgetPartInfo info; PaintWidgetPartInfo info;
@ -69,7 +76,7 @@ void Notifications::onPaint(PaintEvent& ev)
if (isSelected()) if (isSelected())
info.styleFlags |= ui::Style::Layer::kSelected; info.styleFlags |= ui::Style::Layer::kSelected;
theme()->paintWidgetPart(g, m_flagStyle, clientBounds(), info); theme->paintWidgetPart(g, theme->styles.flag(), clientBounds(), info);
} }
void Notifications::onClick() void Notifications::onClick()

View File

@ -1,5 +1,5 @@
// Aseprite // Aseprite
// Copyright (C) 2020-2023 Igara Studio S.A. // Copyright (C) 2020-2025 Igara Studio S.A.
// Copyright (C) 2001-2017 David Capello // Copyright (C) 2001-2017 David Capello
// //
// This program is distributed under the terms of // This program is distributed under the terms of
@ -9,6 +9,7 @@
#define APP_UI_NOTIFICATIONS_H_INCLUDED #define APP_UI_NOTIFICATIONS_H_INCLUDED
#pragma once #pragma once
#include "app/ui/dockable.h"
#include "ui/button.h" #include "ui/button.h"
#include "ui/menu.h" #include "ui/menu.h"
@ -19,20 +20,24 @@ class Style;
namespace app { namespace app {
class INotificationDelegate; class INotificationDelegate;
class Notifications : public ui::Button { class Notifications : public ui::Button,
public Dockable {
public: public:
Notifications(); Notifications();
void addLink(INotificationDelegate* del); void addLink(INotificationDelegate* del);
bool hasNotifications() const { return m_popup.hasChildren(); } bool hasNotifications() const { return m_popup.hasChildren(); }
// Dockable impl
int dockableAt() const override { return ui::TOP | ui::BOTTOM | ui::LEFT | ui::RIGHT; }
protected: protected:
void onInitTheme(ui::InitThemeEvent& ev) override;
void onSizeHint(ui::SizeHintEvent& ev) override; void onSizeHint(ui::SizeHintEvent& ev) override;
void onPaint(ui::PaintEvent& ev) override; void onPaint(ui::PaintEvent& ev) override;
void onClick() override; void onClick() override;
private: private:
ui::Style* m_flagStyle;
bool m_red; bool m_red;
ui::Menu m_popup; ui::Menu m_popup;
}; };

View File

@ -13,6 +13,7 @@
#include "app/context_observer.h" #include "app/context_observer.h"
#include "app/tools/active_tool_observer.h" #include "app/tools/active_tool_observer.h"
#include "app/ui/doc_observer_widget.h" #include "app/ui/doc_observer_widget.h"
#include "app/ui/dockable.h"
#include "base/time.h" #include "base/time.h"
#include "doc/tile.h" #include "doc/tile.h"
#include "ui/base.h" #include "ui/base.h"
@ -44,7 +45,8 @@ class Tool;
} }
class StatusBar : public DocObserverWidget<ui::HBox>, class StatusBar : public DocObserverWidget<ui::HBox>,
public tools::ActiveToolObserver { public tools::ActiveToolObserver,
public Dockable {
static StatusBar* m_instance; static StatusBar* m_instance;
public: public:
@ -72,6 +74,10 @@ public:
void showBackupIcon(BackupIcon icon); void showBackupIcon(BackupIcon icon);
// Dockable impl
int dockableAt() const override { return ui::TOP | ui::BOTTOM; }
int dockHandleSide() const override { return ui::LEFT; }
protected: protected:
void onInitTheme(ui::InitThemeEvent& ev) override; void onInitTheme(ui::InitThemeEvent& ev) override;
void onResize(ui::ResizeEvent& ev) override; void onResize(ui::ResizeEvent& ev) override;

View File

@ -73,8 +73,9 @@ Tabs::~Tabs()
m_addedTab.reset(); m_addedTab.reset();
m_removedTab.reset(); m_removedTab.reset();
// Stop animation // Stop animation, can cause issues with docks when stopping during close.
stopAnimation(); if (!is_app_state_closing())
stopAnimation();
// Remove all tabs // Remove all tabs
m_list.clear(); m_list.clear();

View File

@ -9,6 +9,7 @@
#define APP_UI_TABS_H_INCLUDED #define APP_UI_TABS_H_INCLUDED
#pragma once #pragma once
#include "app/ui/dockable.h"
#include "base/ref.h" #include "base/ref.h"
#include "text/fwd.h" #include "text/fwd.h"
#include "ui/animated_widget.h" #include "ui/animated_widget.h"
@ -118,7 +119,8 @@ public:
// Tabs control. Used to show opened documents. // Tabs control. Used to show opened documents.
class Tabs : public ui::Widget, class Tabs : public ui::Widget,
public ui::AnimatedWidget { public ui::AnimatedWidget,
public Dockable {
struct Tab { struct Tab {
TabView* view; TabView* view;
std::string text; std::string text;
@ -181,6 +183,9 @@ public:
void removeDropViewPreview(); void removeDropViewPreview();
int getDropTabIndex() const { return m_dropNewIndex; } int getDropTabIndex() const { return m_dropNewIndex; }
// Dockable impl
int dockableAt() const override { return ui::TOP | ui::BOTTOM; }
protected: protected:
bool onProcessMessage(ui::Message* msg) override; bool onProcessMessage(ui::Message* msg) override;
void onInitTheme(ui::InitThemeEvent& ev) override; void onInitTheme(ui::InitThemeEvent& ev) override;

View File

@ -13,6 +13,7 @@
#include "app/docs_observer.h" #include "app/docs_observer.h"
#include "app/loop_tag.h" #include "app/loop_tag.h"
#include "app/pref/preferences.h" #include "app/pref/preferences.h"
#include "app/ui/dockable.h"
#include "app/ui/editor/editor_observer.h" #include "app/ui/editor/editor_observer.h"
#include "app/ui/input_chain_element.h" #include "app/ui/input_chain_element.h"
#include "app/ui/timeline/ani_controls.h" #include "app/ui/timeline/ani_controls.h"
@ -73,7 +74,8 @@ class Timeline : public ui::Widget,
public DocObserver, public DocObserver,
public EditorObserver, public EditorObserver,
public InputChainElement, public InputChainElement,
public TagProvider { public TagProvider,
public Dockable {
public: public:
using Range = view::Range; using Range = view::Range;
using RealRange = view::RealRange; using RealRange = view::RealRange;
@ -155,6 +157,12 @@ public:
void clearAndInvalidateRange(); void clearAndInvalidateRange();
// Dockable impl
int dockableAt() const override
{
return ui::TOP | ui::BOTTOM | ui::LEFT | ui::RIGHT | ui::EXPANSIVE;
}
protected: protected:
bool onProcessMessage(ui::Message* msg) override; bool onProcessMessage(ui::Message* msg) override;
void onInitTheme(ui::InitThemeEvent& ev) override; void onInitTheme(ui::InitThemeEvent& ev) override;

View File

@ -94,9 +94,6 @@ ToolBar::ToolBar() : Widget(kGenericWidget), m_openedRecently(false), m_tipTimer
m_hotTool = NULL; m_hotTool = NULL;
m_hotIndex = NoneIndex; m_hotIndex = NoneIndex;
m_openOnHot = false; m_openOnHot = false;
m_popupWindow = NULL;
m_currentStrip = NULL;
m_tipWindow = NULL;
m_tipOpened = false; m_tipOpened = false;
m_minHeight = 0; m_minHeight = 0;
@ -112,9 +109,6 @@ ToolBar::ToolBar() : Widget(kGenericWidget), m_openedRecently(false), m_tipTimer
ToolBar::~ToolBar() ToolBar::~ToolBar()
{ {
App::instance()->activeToolManager()->remove_observer(this); App::instance()->activeToolManager()->remove_observer(this);
delete m_popupWindow;
delete m_tipWindow;
} }
bool ToolBar::isToolVisible(Tool* tool) bool ToolBar::isToolVisible(Tool* tool)
@ -314,10 +308,14 @@ void ToolBar::onSizeHint(SizeHintEvent& ev)
iconsize.h += border().height(); iconsize.h += border().height();
ev.setSizeHint(iconsize); ev.setSizeHint(iconsize);
#if 0 // The Dock widget will ask for sizeHint() of this widget when
// we open the popup, so we cannot close the recently closed
// popup.
if (m_popupWindow) { if (m_popupWindow) {
closePopupWindow(); closePopupWindow();
closeTipWindow(); closeTipWindow();
} }
#endif
} }
void ToolBar::onResize(ui::ResizeEvent& ev) void ToolBar::onResize(ui::ResizeEvent& ev)
@ -401,6 +399,11 @@ void ToolBar::onVisible(bool visible)
} }
} }
bool ToolBar::isDockedAtLeftSide() const
{
return bounds().center().x < window()->bounds().center().x;
}
int ToolBar::getToolGroupIndex(ToolGroup* group) int ToolBar::getToolGroupIndex(ToolGroup* group)
{ {
ToolBox* toolbox = App::instance()->toolBox(); ToolBox* toolbox = App::instance()->toolBox();
@ -465,7 +468,7 @@ void ToolBar::openPopupWindow(GroupType group_type, int group_index, tools::Tool
// In case this tool contains more than just one tool, show the popup window // In case this tool contains more than just one tool, show the popup window
m_openOnHot = true; m_openOnHot = true;
m_popupWindow = new TransparentPopupWindow( m_popupWindow = std::make_unique<TransparentPopupWindow>(
PopupWindow::ClickBehavior::CloseOnClickOutsideHotRegion); PopupWindow::ClickBehavior::CloseOnClickOutsideHotRegion);
m_closeConn = m_popupWindow->Close.connect([this] { onClosePopup(); }); m_closeConn = m_popupWindow->Close.connect([this] { onClosePopup(); });
m_openedRecently = true; m_openedRecently = true;
@ -474,19 +477,23 @@ void ToolBar::openPopupWindow(GroupType group_type, int group_index, tools::Tool
m_currentStrip = toolstrip; m_currentStrip = toolstrip;
m_popupWindow->addChild(toolstrip); m_popupWindow->addChild(toolstrip);
const int borderWidth = border().width();
Rect rc = getToolGroupBounds(group_index); Rect rc = getToolGroupBounds(group_index);
int w = 0; int w = borderWidth;
for (const auto* tool : tools) { for (const auto* tool : tools) {
(void)tool; (void)tool;
w += bounds().w - border().width() - 1 * guiscale(); w += bounds().w - borderWidth - 1 * guiscale();
} }
rc.x -= w; if (isDockedAtLeftSide())
rc.x = bounds().x2() - borderWidth;
else
rc.x -= w - borderWidth;
rc.w = w; rc.w = w;
// Set hotregion of popup window // Set hotregion of popup window
m_popupWindow->setAutoRemap(false); m_popupWindow->setAutoRemap(false);
ui::fit_bounds(display(), m_popupWindow, rc); ui::fit_bounds(display(), m_popupWindow.get(), rc);
m_popupWindow->setBounds(rc); m_popupWindow->setBounds(rc);
Region rgn(m_popupWindow->boundsOnScreen().enlarge(16 * guiscale())); Region rgn(m_popupWindow->boundsOnScreen().enlarge(16 * guiscale()));
@ -500,8 +507,7 @@ void ToolBar::closePopupWindow()
{ {
if (m_popupWindow) { if (m_popupWindow) {
m_popupWindow->closeWindow(nullptr); m_popupWindow->closeWindow(nullptr);
delete m_popupWindow; m_popupWindow.reset();
m_popupWindow = nullptr;
} }
} }
@ -542,7 +548,7 @@ Point ToolBar::getToolPositionInGroup(const Tool* tool) const
const auto& tools = m_currentStrip->tools(); const auto& tools = m_currentStrip->tools();
const int nth = std::find(tools.begin(), tools.end(), tool) - tools.begin(); const int nth = std::find(tools.begin(), tools.end(), tool) - tools.begin();
return Point(iconsize.w / 2 + nth * (iconsize.w - 1 * guiscale()), iconsize.h); return Point(iconsize.w / 2 + nth * (iconsize.w - border().right()), iconsize.h);
} }
void ToolBar::openTipWindow(ToolGroup* tool_group, Tool* tool) void ToolBar::openTipWindow(ToolGroup* tool_group, Tool* tool)
@ -591,15 +597,31 @@ void ToolBar::openTipWindow(int group_index, Tool* tool)
else else
return; return;
m_tipWindow = new TipWindow(tooltip); m_tipWindow = std::make_unique<TipWindow>(tooltip);
m_tipWindow->remapWindow(); m_tipWindow->remapWindow();
Rect toolrc = getToolGroupBounds(group_index); Rect toolrc = getToolGroupBounds(group_index);
Point arrow = (tool ? getToolPositionInGroup(tool) : Point(0, 0)); Point arrow = (tool ? getToolPositionInGroup(tool) : Point(0, 0));
if (tool && m_popupWindow && m_popupWindow->isVisible())
toolrc.x += arrow.x - m_popupWindow->bounds().w;
m_tipWindow->pointAt(TOP | RIGHT, toolrc, ui::Manager::getDefault()->display()); int pointAt = TOP;
if (isDockedAtLeftSide()) {
pointAt |= LEFT;
toolrc.x -= border().width();
}
else {
pointAt |= RIGHT;
toolrc.x += border().width();
}
// Tooltip for subtools (tools inside groups)
if (tool && m_popupWindow && m_popupWindow->isVisible()) {
if (isDockedAtLeftSide())
toolrc.x += arrow.x + bounds().w - border().width();
else
toolrc.x += arrow.x - m_popupWindow->bounds().w + border().width();
}
m_tipWindow->pointAt(pointAt, toolrc, ui::Manager::getDefault()->display());
if (m_tipOpened) if (m_tipOpened)
m_tipWindow->openWindow(); m_tipWindow->openWindow();
@ -613,8 +635,7 @@ void ToolBar::closeTipWindow()
if (m_tipWindow) { if (m_tipWindow) {
m_tipWindow->closeWindow(NULL); m_tipWindow->closeWindow(NULL);
delete m_tipWindow; m_tipWindow.reset();
m_tipWindow = NULL;
} }
} }
@ -651,7 +672,7 @@ void ToolBar::onClosePopup()
m_openOnHot = false; m_openOnHot = false;
m_hotTool = NULL; m_hotTool = NULL;
m_hotIndex = NoneIndex; m_hotIndex = NoneIndex;
m_currentStrip = NULL; m_currentStrip = nullptr;
invalidate(); invalidate();
} }

View File

@ -1,5 +1,5 @@
// Aseprite // Aseprite
// Copyright (C) 2025 Igara Studio S.A. // Copyright (C) 2021-2025 Igara Studio S.A.
// Copyright (C) 2001-2018 David Capello // Copyright (C) 2001-2018 David Capello
// //
// This program is distributed under the terms of // This program is distributed under the terms of
@ -10,6 +10,7 @@
#pragma once #pragma once
#include "app/tools/active_tool_observer.h" #include "app/tools/active_tool_observer.h"
#include "app/ui/dockable.h"
#include "app/ui/skin/skin_part.h" #include "app/ui/skin/skin_part.h"
#include "gfx/point.h" #include "gfx/point.h"
#include "obs/connection.h" #include "obs/connection.h"
@ -17,6 +18,7 @@
#include "ui/widget.h" #include "ui/widget.h"
#include <map> #include <map>
#include <memory>
namespace ui { namespace ui {
class CloseEvent; class CloseEvent;
@ -32,6 +34,7 @@ class ToolGroup;
// Class to show selected tools for each tool (vertically) // Class to show selected tools for each tool (vertically)
class ToolBar : public ui::Widget, class ToolBar : public ui::Widget,
public Dockable,
public tools::ActiveToolObserver { public tools::ActiveToolObserver {
static ToolBar* m_instance; static ToolBar* m_instance;
@ -52,6 +55,14 @@ public:
void openTipWindow(tools::ToolGroup* toolGroup, tools::Tool* tool); void openTipWindow(tools::ToolGroup* toolGroup, tools::Tool* tool);
void closeTipWindow(); void closeTipWindow();
// Dockable impl
int dockableAt() const override
{
// TODO add future support to dock the tool bar at the
// top/bottom sides
return ui::LEFT | ui::RIGHT;
}
protected: protected:
bool onProcessMessage(ui::Message* msg) override; bool onProcessMessage(ui::Message* msg) override;
void onSizeHint(ui::SizeHintEvent& ev) override; void onSizeHint(ui::SizeHintEvent& ev) override;
@ -62,6 +73,7 @@ protected:
private: private:
enum class GroupType { Regular, Overflow }; enum class GroupType { Regular, Overflow };
bool isDockedAtLeftSide() const;
int getToolGroupIndex(tools::ToolGroup* group); int getToolGroupIndex(tools::ToolGroup* group);
void openPopupWindow(GroupType group_type, void openPopupWindow(GroupType group_type,
int group_index = 0, int group_index = 0,
@ -95,12 +107,12 @@ private:
bool m_openedRecently; bool m_openedRecently;
// Window displayed to show a tool-group // Window displayed to show a tool-group
ui::PopupWindow* m_popupWindow; std::unique_ptr<ui::PopupWindow> m_popupWindow;
class ToolStrip; class ToolStrip;
ToolStrip* m_currentStrip; ToolStrip* m_currentStrip = nullptr;
// Tool-tip window // Tool-tip window
ui::TipWindow* m_tipWindow; std::unique_ptr<ui::TipWindow> m_tipWindow;
ui::Timer m_tipTimer; ui::Timer m_tipTimer;
bool m_tipOpened; bool m_tipOpened;

View File

@ -19,7 +19,8 @@ namespace app {
class WorkspaceTabs; class WorkspaceTabs;
class Workspace : public ui::Widget, class Workspace : public ui::Widget,
public app::InputChainElement { public app::InputChainElement,
public Dockable {
public: public:
typedef WorkspaceViews::iterator iterator; typedef WorkspaceViews::iterator iterator;
@ -75,6 +76,11 @@ public:
WorkspacePanel* mainPanel() { return &m_mainPanel; } WorkspacePanel* mainPanel() { return &m_mainPanel; }
// Dockable impl
int dockableAt() const override { return 0; }
int dockHandleSide() const override { return 0; } // No handles
// Signals
obs::signal<void()> BeforeViewChanged; obs::signal<void()> BeforeViewChanged;
obs::signal<void()> ActiveViewChanged; obs::signal<void()> ActiveViewChanged;

View File

@ -20,6 +20,10 @@ public:
WorkspaceTabs(TabsDelegate* tabsDelegate); WorkspaceTabs(TabsDelegate* tabsDelegate);
~WorkspaceTabs(); ~WorkspaceTabs();
// Dockable impl
int dockableAt() const override { return ui::TOP | ui::BOTTOM; }
int dockHandleSide() const override { return ui::LEFT; }
WorkspacePanel* panel() const { return m_panel; } WorkspacePanel* panel() const { return m_panel; }
void setPanel(WorkspacePanel* panel); void setPanel(WorkspacePanel* panel);

View File

@ -1,5 +1,5 @@
// Aseprite UI Library // Aseprite UI Library
// Copyright (C) 2019-2023 Igara Studio S.A. // Copyright (C) 2019-2025 Igara Studio S.A.
// Copyright (C) 2001-2017 David Capello // Copyright (C) 2001-2017 David Capello
// //
// This file is released under the terms of the MIT license. // This file is released under the terms of the MIT license.
@ -38,6 +38,8 @@ public:
Items::iterator begin() { return m_items.begin(); } Items::iterator begin() { return m_items.begin(); }
Items::iterator end() { return m_items.end(); } Items::iterator end() { return m_items.end(); }
bool empty() const { return m_items.empty(); }
const Items& items() { return m_items; }
void setEditable(bool state); void setEditable(bool state);
void setClickOpen(bool state); void setClickOpen(bool state);

View File

@ -218,16 +218,16 @@ bool TipWindow::pointAt(int arrowAlign, const gfx::Rect& target, const ui::Displ
if (get_multiple_displays()) { if (get_multiple_displays()) {
const gfx::Rect waBounds = nativeParentWindow->screen()->workarea(); const gfx::Rect waBounds = nativeParentWindow->screen()->workarea();
gfx::Point pt = nativeParentWindow->pointToScreen(gfx::Point(x, y)); gfx::Point pt = nativeParentWindow->pointToScreen(gfx::Point(x, y));
pt.x = std::clamp(pt.x, waBounds.x, waBounds.x2() - w); pt.x = std::clamp(pt.x, waBounds.x, std::max(waBounds.x, waBounds.x2() - w));
pt.y = std::clamp(pt.y, waBounds.y, waBounds.y2() - h); pt.y = std::clamp(pt.y, waBounds.y, std::max(waBounds.y, waBounds.y2() - h));
pt = nativeParentWindow->pointFromScreen(pt); pt = nativeParentWindow->pointFromScreen(pt);
x = pt.x; x = pt.x;
y = pt.y; y = pt.y;
} }
else { else {
const gfx::Rect displayBounds = display->bounds(); const gfx::Rect displayBounds = display->bounds();
x = std::clamp(x, displayBounds.x, displayBounds.x2() - w); x = std::clamp(x, displayBounds.x, std::max(displayBounds.x, displayBounds.x2() - w));
y = std::clamp(y, displayBounds.y, displayBounds.y2() - h); y = std::clamp(y, displayBounds.y, std::max(displayBounds.y, displayBounds.y2() - h));
} }
if (m_target.intersects(gfx::Rect(x, y, w, h))) { if (m_target.intersects(gfx::Rect(x, y, w, h))) {