diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
commit | cca66b9ec4e494c1d919bff0f71a820d8afab1fa (patch) | |
tree | 146f39ded1c938019e1ed42d30923c2ac9e86789 /src/ui/dialog/objects.cpp | |
parent | Initial commit. (diff) | |
download | inkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.tar.xz inkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.zip |
Adding upstream version 1.2.2.upstream/1.2.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | src/ui/dialog/objects.cpp | 1468 |
1 files changed, 1468 insertions, 0 deletions
diff --git a/src/ui/dialog/objects.cpp b/src/ui/dialog/objects.cpp new file mode 100644 index 0000000..fb8e98f --- /dev/null +++ b/src/ui/dialog/objects.cpp @@ -0,0 +1,1468 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * A simple panel for objects (originally developed for Ponyscape, an Inkscape derivative) + * + * Authors: + * Martin Owens, completely rewritten + * Theodore Janeczko + * Tweaked by Liam P White for use in Inkscape + * Tavmjong Bah + * + * Copyright (C) Theodore Janeczko 2012 <flutterguy317@gmail.com> + * Tavmjong Bah 2017 + * Martin Owens 2020-2021 + * + * Released under GNU GPL v2+, read the file 'COPYING' for more information. + */ + +#include "objects.h" + +#include <gtkmm/cellrenderer.h> +#include <gtkmm/icontheme.h> +#include <gtkmm/imagemenuitem.h> +#include <gtkmm/separatormenuitem.h> +#include <glibmm/main.h> +#include <glibmm/i18n.h> + +#include "desktop-style.h" +#include "desktop.h" +#include "document-undo.h" +#include "document.h" +#include "filter-chemistry.h" +#include "inkscape.h" +#include "layer-manager.h" +#include "message-stack.h" + +#include "actions/actions-tools.h" + +#include "include/gtkmm_version.h" + +#include "object/filters/blend.h" +#include "object/filters/gaussian-blur.h" +#include "object/sp-clippath.h" +#include "object/sp-mask.h" +#include "object/sp-root.h" +#include "object/sp-shape.h" +#include "style.h" + +#include "ui/dialog-events.h" +#include "ui/icon-loader.h" +#include "ui/icon-names.h" +#include "ui/selected-color.h" +#include "ui/shortcuts.h" +#include "ui/tools/node-tool.h" + +#include "ui/contextmenu.h" +#include "ui/util.h" +#include "ui/widget/canvas.h" +#include "ui/widget/imagetoggler.h" +#include "ui/widget/shapeicon.h" + +// alpha (transparency) multipliers corresponding to item selection state combinations (SelectionState) +// when 0 - do not color item's background +static double const SELECTED_ALPHA[8] = { + 0.00, //0 not selected + 1.00, //1 selected + 0.50, //2 layer focused + 1.00, //3 layer focused & selected + 0.00, //4 child of focused layer + 1.00, //5 selected child of focused layer + 0.50, //6 2 and 4 + 1.00 //7 1, 2 and 4 +}; + +//#define DUMP_LAYERS 1 + +namespace Inkscape { +namespace UI { +namespace Dialog { + +class ObjectWatcher : public Inkscape::XML::NodeObserver +{ +public: + ObjectWatcher() = delete; + ObjectWatcher(ObjectsPanel *panel, SPItem *, Gtk::TreeRow *row, bool layers_only); + ~ObjectWatcher() override; + + void updateRowInfo(); + void updateRowHighlight(); + void updateRowAncestorState(bool invisible, bool locked); + void updateRowBg(guint32 rgba = 0.0); + + ObjectWatcher *findChild(Node *node); + void addDummyChild(); + bool addChild(SPItem *, bool dummy = true); + void addChildren(SPItem *, bool dummy = false); + void setSelectedBit(SelectionState mask, bool enabled); + void setSelectedBitRecursive(SelectionState mask, bool enabled); + void setSelectedBitChildren(SelectionState mask, bool enabled); + void moveChild(Node &child, Node *sibling); + + Gtk::TreeNodeChildren getChildren() const; + Gtk::TreeIter getChildIter(Node *) const; + + void notifyChildAdded(Node &, Node &, Node *) override; + void notifyChildRemoved(Node &, Node &, Node *) override; + void notifyChildOrderChanged(Node &, Node &child, Node *, Node *) override; + void notifyAttributeChanged(Node &, GQuark, Util::ptr_shared, Util::ptr_shared) override; + + /// Associate this watcher with a tree row + void setRow(const Gtk::TreeModel::Path &path) + { + assert(path); + row_ref = Gtk::TreeModel::RowReference(panel->_store, path); + } + void setRow(const Gtk::TreeModel::Row &row) + { + setRow(panel->_store->get_path(row)); + } + + // Get the path out of this watcher + Gtk::TreeModel::Path getTreePath() const { + return row_ref.get_path(); + } + + /// True if this watchr has a valid row reference. + bool hasRow() const { return bool(row_ref); } + + /// Transfer a child watcher to its new parent + void transferChild(Node *childnode) + { + auto *target = panel->getWatcher(childnode->parent()); + assert(target != this); + auto nh = child_watchers.extract(childnode); + assert(nh); + bool inserted = target->child_watchers.insert(std::move(nh)).inserted; + assert(inserted); + } + + /// The XML node associated with this watcher. + Node *getRepr() const { return node; } + std::optional<Gtk::TreeRow> getRow() const { + if (auto path = row_ref.get_path()) { + if(auto iter = panel->_store->get_iter(path)) { + return *iter; + } + } + return std::nullopt; + } + + std::unordered_map<Node const *, std::unique_ptr<ObjectWatcher>> child_watchers; + +private: + Node *node; + Gtk::TreeModel::RowReference row_ref; + ObjectsPanel *panel; + SelectionState selection_state; + bool layers_only; +}; + +class ObjectsPanel::ModelColumns : public Gtk::TreeModel::ColumnRecord +{ +public: + ModelColumns() + { + add(_colNode); + add(_colLabel); + add(_colType); + add(_colIconColor); + add(_colClipMask); + add(_colBgColor); + add(_colInvisible); + add(_colLocked); + add(_colAncestorInvisible); + add(_colAncestorLocked); + add(_colHover); + } + ~ModelColumns() override = default; + Gtk::TreeModelColumn<Node*> _colNode; + Gtk::TreeModelColumn<Glib::ustring> _colLabel; + Gtk::TreeModelColumn<Glib::ustring> _colType; + Gtk::TreeModelColumn<unsigned int> _colIconColor; + Gtk::TreeModelColumn<unsigned int> _colClipMask; + Gtk::TreeModelColumn<Gdk::RGBA> _colBgColor; + Gtk::TreeModelColumn<bool> _colInvisible; + Gtk::TreeModelColumn<bool> _colLocked; + Gtk::TreeModelColumn<bool> _colAncestorInvisible; + Gtk::TreeModelColumn<bool> _colAncestorLocked; + Gtk::TreeModelColumn<bool> _colHover; +}; + +/** + * Gets an instance of the Objects panel + */ +ObjectsPanel& ObjectsPanel::getInstance() +{ + return *new ObjectsPanel(); +} + +/** + * Creates a new ObjectWatcher, a gtk TreeView iterated watching device. + * + * @param panel The panel to which the object watcher belongs + * @param obj The object to watch + * @param iter The optional list store iter for the item, if not provided, + * assumes this is the root 'document' object. + * @param layers If true, only show and watch layers, not groups or other objects. + */ +ObjectWatcher::ObjectWatcher(ObjectsPanel* panel, SPItem* obj, Gtk::TreeRow *row, bool layers) + : panel(panel) + , layers_only(layers) + , row_ref() + , selection_state(0) + , node(obj->getRepr()) +{ + if(row != nullptr) { + assert(row->children().empty()); + setRow(*row); + updateRowInfo(); + } + node->addObserver(*this); + + // Only show children for groups (and their subclasses like SPAnchor or SPRoot) + if (!dynamic_cast<SPGroup const*>(obj)) { + return; + } + // Add children as a dummy row to avoid excensive execution when + // the tree is really large, but not in layers mode. + addChildren(obj, (bool)row && !layers); +} +ObjectWatcher::~ObjectWatcher() +{ + node->removeObserver(*this); + Gtk::TreeModel::Path path; + if (bool(row_ref) && (path = row_ref.get_path())) { + auto iter = panel->_store->get_iter(path); + if(iter) { + panel->_store->erase(iter); + } + } + child_watchers.clear(); +} + +/** + * Update the information in the row from the stored node + */ +void ObjectWatcher::updateRowInfo() { + if (auto item = dynamic_cast<SPItem *>(panel->getObject(node))) { + assert(row_ref); + assert(row_ref.get_path()); + + auto _model = panel->_model; + auto row = *panel->_store->get_iter(row_ref.get_path()); + row[_model->_colNode] = node; + + // show ids without "#" + char const *id = item->getId(); + row[_model->_colLabel] = (id && !item->label()) ? id : item->defaultLabel(); + + row[_model->_colType] = item->typeName(); + row[_model->_colClipMask] = + (item->getClipObject() ? Inkscape::UI::Widget::OVERLAY_CLIP : 0) | + (item->getMaskObject() ? Inkscape::UI::Widget::OVERLAY_MASK : 0); + row[_model->_colInvisible] = item->isHidden(); + row[_model->_colLocked] = !item->isSensitive(); + + updateRowHighlight(); + updateRowAncestorState(row[_model->_colAncestorInvisible], row[_model->_colAncestorLocked]); + } +} + +/** + * Propegate changes to the highlight color to all children. + */ +void ObjectWatcher::updateRowHighlight() { + if (auto item = dynamic_cast<SPItem *>(panel->getObject(node))) { + auto row = *panel->_store->get_iter(row_ref.get_path()); + auto new_color = item->highlight_color(); + if (new_color != row[panel->_model->_colIconColor]) { + row[panel->_model->_colIconColor] = new_color; + updateRowBg(new_color); + for (auto &watcher : child_watchers) { + watcher.second->updateRowHighlight(); + } + } + } +} + +/** + * Propegate a change in visibility or locked state to all children + */ +void ObjectWatcher::updateRowAncestorState(bool invisible, bool locked) { + auto _model = panel->_model; + auto row = *panel->_store->get_iter(row_ref.get_path()); + row[_model->_colAncestorInvisible] = invisible; + row[_model->_colAncestorLocked] = locked; + for (auto &watcher : child_watchers) { + watcher.second->updateRowAncestorState( + invisible || row[_model->_colInvisible], + locked || row[_model->_colLocked]); + } +} + +Gdk::RGBA selection_color; + +/** + * Updates the row's background colour as indicated by it's selection. + */ +void ObjectWatcher::updateRowBg(guint32 rgba) +{ + assert(row_ref); + if (auto row = *panel->_store->get_iter(row_ref.get_path())) { + auto alpha = SELECTED_ALPHA[selection_state]; + if (alpha == 0.0) { + row[panel->_model->_colBgColor] = Gdk::RGBA(); + return; + } + + const auto& sel = selection_color; + auto gdk_color = Gdk::RGBA(); + gdk_color.set_red(sel.get_red()); + gdk_color.set_green(sel.get_green()); + gdk_color.set_blue(sel.get_blue()); + gdk_color.set_alpha(sel.get_alpha() * alpha); + row[panel->_model->_colBgColor] = gdk_color; + } +} + +/** + * Flip the selected state bit on or off as needed, calls updateRowBg if changed. + * + * @param mask - The selection bit to set or unset + * @param enabled - If the bit should be set or unset + */ +void ObjectWatcher::setSelectedBit(SelectionState mask, bool enabled) { + if (!row_ref) return; + SelectionState value = selection_state; + SelectionState original = value; + if (enabled) { + value |= mask; + } else { + value &= ~mask; + } + if (value != original) { + selection_state = value; + updateRowBg(); + } +} + +/** + * Flip the selected state bit on or off as needed, on this watcher and all + * its direct and indirect children. + */ +void ObjectWatcher::setSelectedBitRecursive(SelectionState mask, bool enabled) +{ + setSelectedBit(mask, enabled); + setSelectedBitChildren(mask, enabled); +} +void ObjectWatcher::setSelectedBitChildren(SelectionState mask, bool enabled) +{ + for (auto &pair : child_watchers) { + pair.second->setSelectedBitRecursive(mask, enabled); + } +} + +/** + * Find the child watcher for the given node. + */ +ObjectWatcher *ObjectWatcher::findChild(Node *node) +{ + auto it = child_watchers.find(node); + if (it != child_watchers.end()) { + return it->second.get(); + } + return nullptr; +} + +/** + * Add the child object to this node. + * + * @param child - SPObject to be added + * @param dummy - Add a dummy objects (hidden) instead + * + * @returns true if child added was a dummy objects + */ +bool ObjectWatcher::addChild(SPItem *child, bool dummy) +{ + auto group = dynamic_cast<SPGroup *>(child); + if (layers_only && (!group || group->layerMode() != SPGroup::LAYER)) { + return false; + } + + auto const children = getChildren(); + if (dummy && row_ref) { + if (children.empty()) { + auto const iter = panel->_store->append(children); + assert(panel->isDummy(*iter)); + return true; + } else if (panel->isDummy(children[0])) { + return false; + } + } + + auto *node = child->getRepr(); + assert(node); + Gtk::TreeModel::Row row = *(panel->_store->prepend(children)); + + // Ancestor states are handled inside the list store (so we don't have to re-ask every update) + auto _model = panel->_model; + if (row_ref) { + auto parent_row = *panel->_store->get_iter(row_ref.get_path()); + row[_model->_colAncestorInvisible] = parent_row[_model->_colAncestorInvisible] || parent_row[_model->_colInvisible]; + row[_model->_colAncestorLocked] = parent_row[_model->_colAncestorLocked] || parent_row[_model->_colLocked]; + } else { + row[_model->_colAncestorInvisible] = false; + row[_model->_colAncestorLocked] = false; + } + + auto &watcher = child_watchers[node]; + assert(!watcher); + watcher.reset(new ObjectWatcher(panel, child, &row, layers_only)); + + // Make sure new children have the right focus set. + if ((selection_state & LAYER_FOCUSED) != 0) { + watcher->setSelectedBit(LAYER_FOCUS_CHILD, true); + } + return false; +} + +/** + * Add all SPItem children as child rows. + */ +void ObjectWatcher::addChildren(SPItem *obj, bool dummy) +{ + assert(child_watchers.empty()); + + for (auto &child : obj->children) { + if (auto item = dynamic_cast<SPItem *>(&child)) { + if (addChild(item, dummy) && dummy) { + // one dummy child is enough to make the group expandable + break; + } + } + } +} + +/** + * Move the child to just after the given sibling + * + * @param child - SPObject to be moved + * @param sibling - Optional sibling Object to add next to, if nullptr the + * object is moved to BEFORE the first item. + */ +void ObjectWatcher::moveChild(Node &child, Node *sibling) +{ + auto child_iter = getChildIter(&child); + if (!child_iter) + return; // This means the child was never added, probably not an SPItem. + + // sibling might not be an SPItem and thus not be represented in the + // TreeView. Find the closest SPItem and use that for the reordering. + while (sibling && !dynamic_cast<SPItem const *>(panel->getObject(sibling))) { + sibling = sibling->prev(); + } + + auto sibling_iter = getChildIter(sibling); + panel->_store->move(child_iter, sibling_iter); +} + +/** + * Get the TreeRow's children iterator + * + * @returns Gtk Tree Node Children iterator + */ +Gtk::TreeNodeChildren ObjectWatcher::getChildren() const +{ + Gtk::TreeModel::Path path; + if (row_ref && (path = row_ref.get_path())) { + return panel->_store->get_iter(path)->children(); + } + assert(!row_ref); + return panel->_store->children(); +} + +/** + * Convert SPObject to TreeView Row, assuming the object is a child. + * + * @param child - The child object to find in this branch + * @returns Gtk TreeRow for the child, or end() if not found + */ +Gtk::TreeIter ObjectWatcher::getChildIter(Node *node) const +{ + auto childrows = getChildren(); + + if (!node) { + return childrows.end(); + } + + // Note: TreeRow inherits from TreeIter, so this `row` variable is + // also an iterator and a valid return value. + for (auto &row : childrows) { + if (panel->getRepr(row) == node) { + return row; + } + } + // In layer mode, we will come here for all non-layers + return childrows.begin(); +} + +void ObjectWatcher::notifyChildAdded( Node &node, Node &child, Node *prev ) +{ + assert(this->node == &node); + // Ignore XML nodes which are not displayable items + if (auto item = dynamic_cast<SPItem *>(panel->getObject(&child))) { + addChild(item); + moveChild(child, prev); + } +} +void ObjectWatcher::notifyChildRemoved( Node &node, Node &child, Node* /*prev*/ ) +{ + assert(this->node == &node); + + if (child_watchers.erase(&child) > 0) { + return; + } + + if (node.firstChild() == nullptr) { + assert(row_ref); + auto iter = panel->_store->get_iter(row_ref.get_path()); + panel->removeDummyChildren(*iter); + } +} +void ObjectWatcher::notifyChildOrderChanged( Node &parent, Node &child, Node */*old_prev*/, Node *new_prev ) +{ + assert(this->node == &parent); + + moveChild(child, new_prev); +} +void ObjectWatcher::notifyAttributeChanged( Node &node, GQuark name, Util::ptr_shared /*old_value*/, Util::ptr_shared /*new_value*/ ) +{ + assert(this->node == &node); + + // The root <svg> node doesn't have a row + if (this == panel->getRootWatcher()) { + return; + } + + // Almost anything could change the icon, so update upon any change, defer for lots of updates. + + // examples of not-so-obvious cases: + // - width/height: Can change type "circle" to an "ellipse" + + static std::set<GQuark> const excluded{ + g_quark_from_static_string("transform"), + g_quark_from_static_string("x"), + g_quark_from_static_string("y"), + g_quark_from_static_string("d"), + g_quark_from_static_string("sodipodi:nodetypes"), + }; + + if (excluded.count(name)) { + return; + } + + updateRowInfo(); +} + + +/** + * Get the object from the node. + * + * @param node - XML Node involved in the signal. + * @returns SPObject matching the node, returns nullptr if not found. + */ +SPObject *ObjectsPanel::getObject(Node *node) { + if (node != nullptr && getDocument()) + return getDocument()->getObjectByRepr(node); + return nullptr; +} + +/** + * Get the object watcher from the xml node (reverse lookup), it uses a ancesstor + * recursive pattern to match up with the root_watcher. + * + * @param node - The node to look up. + * @return the ObjectWatcher object if it's possible to find. + */ +ObjectWatcher* ObjectsPanel::getWatcher(Node *node) +{ + assert(node); + if (root_watcher->getRepr() == node) { + return root_watcher; + } else if (node->parent()) { + if (auto parent_watcher = getWatcher(node->parent())) { + return parent_watcher->findChild(node); + } + } + return nullptr; +} + +class ColorTagRenderer : public Gtk::CellRenderer { +public: + ColorTagRenderer() : + Glib::ObjectBase(typeid(CellRenderer)), + Gtk::CellRenderer(), + _property_color(*this, "tagcolor", 0) { + property_mode() = Gtk::CELL_RENDERER_MODE_ACTIVATABLE; + + int dummy_width; + // height size is not critical + Gtk::IconSize::lookup(Gtk::ICON_SIZE_MENU, dummy_width, _height); + } + + ~ColorTagRenderer() override = default; + + Glib::PropertyProxy<unsigned int> property_color() { + return _property_color.get_proxy(); + } + + int get_width() const { + return _width; + } + + sigc::signal<void, const Glib::ustring&> signal_clicked() { + return _signal_clicked; + } + +private: + void render_vfunc(const Cairo::RefPtr<Cairo::Context>& cr, + Gtk::Widget& widget, + const Gdk::Rectangle& background_area, + const Gdk::Rectangle& cell_area, + Gtk::CellRendererState flags) override { + cr->rectangle(cell_area.get_x(), cell_area.get_y(), cell_area.get_width(), cell_area.get_height()); + ColorRGBA color(_property_color.get_value()); + cr->set_source_rgb(color[0], color[1], color[2]); + cr->fill(); + } + + void get_preferred_width_vfunc(Gtk::Widget& widget, int& min_w, int& nat_w) const override { + min_w = nat_w = _width; + } + + void get_preferred_height_vfunc(Gtk::Widget& widget, int& min_h, int& nat_h) const override { + min_h = 1; + nat_h = _height; + } + + bool activate_vfunc(GdkEvent* event, Gtk::Widget& /*widget*/, const Glib::ustring& path, + const Gdk::Rectangle& /*background_area*/, const Gdk::Rectangle& /*cell_area*/, + Gtk::CellRendererState /*flags*/) override { + _signal_clicked.emit(path); + return false; + } + + int _width = 8; + int _height; + Glib::Property<unsigned int> _property_color; + sigc::signal<void, const Glib::ustring&> _signal_clicked; +}; + +/** + * Constructor + */ +ObjectsPanel::ObjectsPanel() : + DialogBase("/dialogs/objects", "Objects"), + root_watcher(nullptr), + _model(nullptr), + _layer(nullptr), + _is_editing(false), + _page(Gtk::ORIENTATION_VERTICAL), + _color_picker(_("Highlight color"), "", 0, true) +{ + //Create the tree model and store + ModelColumns *zoop = new ModelColumns(); + _model = zoop; + + _store = Gtk::TreeStore::create( *zoop ); + + _color_picker.hide(); + + //Set up the tree + _tree.set_model( _store ); + _tree.set_headers_visible(false); + // Reorderable means that we allow drag-and-drop, but we only allow that + // when at least one row is selected + _tree.set_reorderable(true); + _tree.enable_model_drag_dest (Gdk::ACTION_MOVE); + + //Label + _name_column = Gtk::manage(new Gtk::TreeViewColumn()); + _text_renderer = Gtk::manage(new Gtk::CellRendererText()); + _text_renderer->property_editable() = true; + _text_renderer->property_ellipsize().set_value(Pango::ELLIPSIZE_END); + _text_renderer->signal_editing_started().connect([=](Gtk::CellEditable*,const Glib::ustring&){ + _is_editing = true; + }); + _text_renderer->signal_editing_canceled().connect([=](){ + _is_editing = false; + }); + _text_renderer->signal_edited().connect([=](const Glib::ustring&,const Glib::ustring&){ + _is_editing = false; + }); + + const int icon_col_width = 24; + auto icon_renderer = Gtk::manage(new Inkscape::UI::Widget::CellRendererItemIcon()); + icon_renderer->property_xpad() = 2; + icon_renderer->property_width() = icon_col_width; + _tree.append_column(*_name_column); + _name_column->set_expand(true); + _name_column->pack_start(*icon_renderer, false); + _name_column->pack_start(*_text_renderer, true); + _name_column->add_attribute(_text_renderer->property_text(), _model->_colLabel); + _name_column->add_attribute(_text_renderer->property_cell_background_rgba(), _model->_colBgColor); + _name_column->add_attribute(icon_renderer->property_shape_type(), _model->_colType); + _name_column->add_attribute(icon_renderer->property_color(), _model->_colIconColor); + _name_column->add_attribute(icon_renderer->property_clipmask(), _model->_colClipMask); + _name_column->add_attribute(icon_renderer->property_cell_background_rgba(), _model->_colBgColor); + + // Visible icon + auto *eyeRenderer = Gtk::manage( new Inkscape::UI::Widget::ImageToggler( + INKSCAPE_ICON("object-hidden"), INKSCAPE_ICON("object-visible"))); + int visibleColNum = _tree.append_column("vis", *eyeRenderer) - 1; + if (auto eye = _tree.get_column(visibleColNum)) { + eye->add_attribute(eyeRenderer->property_active(), _model->_colInvisible); + eye->add_attribute(eyeRenderer->property_cell_background_rgba(), _model->_colBgColor); + eye->add_attribute(eyeRenderer->property_activatable(), _model->_colHover); + eye->add_attribute(eyeRenderer->property_gossamer(), _model->_colAncestorInvisible); + eye->set_fixed_width(icon_col_width); + _eye_column = eye; + } + + // Unlocked icon + Inkscape::UI::Widget::ImageToggler * lockRenderer = Gtk::manage( new Inkscape::UI::Widget::ImageToggler( + INKSCAPE_ICON("object-locked"), INKSCAPE_ICON("object-unlocked"))); + int lockedColNum = _tree.append_column("lock", *lockRenderer) - 1; + if (auto lock = _tree.get_column(lockedColNum)) { + lock->add_attribute(lockRenderer->property_active(), _model->_colLocked); + lock->add_attribute(lockRenderer->property_cell_background_rgba(), _model->_colBgColor); + lock->add_attribute(lockRenderer->property_activatable(), _model->_colHover); + lock->add_attribute(lockRenderer->property_gossamer(), _model->_colAncestorLocked); + lock->set_fixed_width(icon_col_width); + _lock_column = lock; + } + + // hierarchy indicator - using item's layer highlight color + auto tag_renderer = Gtk::manage(new ColorTagRenderer()); + int tag_column = _tree.append_column("tag", *tag_renderer) - 1; + if (auto tag = _tree.get_column(tag_column)) { + tag->add_attribute(tag_renderer->property_color(), _model->_colIconColor); + tag->set_fixed_width(tag_renderer->get_width()); + } + tag_renderer->signal_clicked().connect([=](const Glib::ustring& path) { + // object's color indicator clicked - open color picker + _clicked_item_row = *_store->get_iter(path); + if (auto item = getItem(_clicked_item_row)) { + // find object's color + _color_picker.setRgba32(item->highlight_color()); + _color_picker.open(); + } + }); + + _color_picker.connectChanged([=](guint rgba) { + if (auto item = getItem(_clicked_item_row)) { + item->setHighlight(rgba); + DocumentUndo::maybeDone(getDocument(), "highligh-color", _("Set item highlight color"), INKSCAPE_ICON("dialog-object-properties")); + } + }); + + //Set the expander and search columns + _tree.set_expander_column(*_name_column); + // Disable search (it doesn't make much sense) + _tree.set_search_column(-1); + _tree.set_enable_search(false); + _tree.get_selection()->set_mode(Gtk::SELECTION_NONE); + + //Set up tree signals + _tree.signal_button_press_event().connect(sigc::mem_fun(*this, &ObjectsPanel::_handleButtonEvent), false); + _tree.signal_button_release_event().connect(sigc::mem_fun(*this, &ObjectsPanel::_handleButtonEvent), false); + _tree.signal_key_press_event().connect(sigc::mem_fun(*this, &ObjectsPanel::_handleKeyEvent), false); + _tree.signal_key_release_event().connect(sigc::mem_fun(*this, &ObjectsPanel::_handleKeyEvent), false); + _tree.signal_motion_notify_event().connect(sigc::mem_fun(*this, &ObjectsPanel::_handleMotionEvent), false); + + // Set a status bar text when entering the widget + _tree.signal_enter_notify_event().connect([=](GdkEventCrossing*){ + getDesktop()->messageStack()->flash(Inkscape::NORMAL_MESSAGE, + _("<b>Hold ALT</b> while hovering over item to highlight, <b>hold SHIFT</b> and click to hide/lock all.")); + return false; + }, false); + // watch mouse leave too to clear any state. + _tree.signal_leave_notify_event().connect([=](GdkEventCrossing*){ return _handleMotionEvent(nullptr); }, false); + + // Before expanding a row, replace the dummy child with the actual children + _tree.signal_test_expand_row().connect([this](const Gtk::TreeModel::iterator &iter, const Gtk::TreeModel::Path &) { + if (cleanDummyChildren(*iter)) { + if (auto selection = getSelection()) { + selectionChanged(selection); + } + } + return false; + }); + + _tree.signal_drag_motion().connect(sigc::mem_fun(*this, &ObjectsPanel::on_drag_motion), false); + _tree.signal_drag_drop().connect(sigc::mem_fun(*this, &ObjectsPanel::on_drag_drop), false); + _tree.signal_drag_begin().connect(sigc::mem_fun(*this, &ObjectsPanel::on_drag_start), false); + _tree.signal_drag_end().connect(sigc::mem_fun(*this, &ObjectsPanel::on_drag_end), false); + + //Set up the label editing signals + _text_renderer->signal_edited().connect(sigc::mem_fun(*this, &ObjectsPanel::_handleEdited)); + + //Set up the scroller window and pack the page + // turn off overlay scrollbars - they block access to the 'lock' icon + _scroller.set_overlay_scrolling(false); + _scroller.add(_tree); + _scroller.set_policy( Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC ); + _scroller.set_shadow_type(Gtk::SHADOW_IN); + Gtk::Requisition sreq; + Gtk::Requisition sreq_natural; + _scroller.get_preferred_size(sreq_natural, sreq); + int minHeight = 70; + if (sreq.height < minHeight) { + // Set a min height to see the layers when used with Ubuntu liboverlay-scrollbar + _scroller.set_size_request(sreq.width, minHeight); + } + + _page.pack_start(_buttonsRow, Gtk::PACK_SHRINK); + _page.pack_end(_scroller, Gtk::PACK_EXPAND_WIDGET); + pack_start(_page, Gtk::PACK_EXPAND_WIDGET); + + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + { + auto child = Glib::wrap(sp_get_icon_image("layer-duplicate", GTK_ICON_SIZE_SMALL_TOOLBAR)); + child->show(); + _object_mode.add(*child); + _object_mode.set_relief(Gtk::RELIEF_NONE); + } + _object_mode.set_tooltip_text(_("Switch to layers only view.")); + _object_mode.property_active() = prefs->getBool("/dialogs/objects/layers_only", false); + _object_mode.property_active().signal_changed().connect(sigc::mem_fun(*this, &ObjectsPanel::_objects_toggle)); + + _buttonsPrimary.pack_start(_object_mode, Gtk::PACK_SHRINK); + _buttonsPrimary.pack_start(*_addBarButton(INKSCAPE_ICON("layer-new"), _("Add layer..."), "win.layer-new"), Gtk::PACK_SHRINK); + + _buttonsSecondary.pack_end(*_addBarButton(INKSCAPE_ICON("edit-delete"), _("Remove object"), "app.delete-selection"), Gtk::PACK_SHRINK); + _buttonsSecondary.pack_end(*_addBarButton(INKSCAPE_ICON("go-down"), _("Move Down"), "app.selection-stack-down"), Gtk::PACK_SHRINK); + _buttonsSecondary.pack_end(*_addBarButton(INKSCAPE_ICON("go-up"), _("Move Up"), "app.selection-stack-up"), Gtk::PACK_SHRINK); + + _buttonsRow.pack_start(_buttonsPrimary, Gtk::PACK_SHRINK); + _buttonsRow.pack_end(_buttonsSecondary, Gtk::PACK_SHRINK); + + selection_color = get_background_color(_tree.get_style_context(), Gtk::STATE_FLAG_SELECTED); + _tree_style = _tree.signal_style_updated().connect([=](){ + selection_color = get_background_color(_tree.get_style_context(), Gtk::STATE_FLAG_SELECTED); + + if (!root_watcher) return; + for (auto&& kv : root_watcher->child_watchers) { + if (kv.second) { + kv.second->updateRowHighlight(); + } + } + }); + // Clear and update entire tree (do not use this in changed/modified signals) + _watch_object_mode = prefs->createObserver("/dialogs/objects/layers_only", [=]() { setRootWatcher(); }); + + update(); + show_all_children(); +} + +/** + * Destructor + */ +ObjectsPanel::~ObjectsPanel() +{ + if (root_watcher) { + delete root_watcher; + } + root_watcher = nullptr; + + if (_model) { + delete _model; + _model = nullptr; + } +} + +void ObjectsPanel::_objects_toggle() +{ + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + prefs->setBool("/dialogs/objects/layers_only", _object_mode.get_active()); +} + +void ObjectsPanel::desktopReplaced() +{ + layer_changed.disconnect(); + + if (auto desktop = getDesktop()) { + layer_changed = desktop->layerManager().connectCurrentLayerChanged(sigc::mem_fun(*this, &ObjectsPanel::layerChanged)); + } +} + +void ObjectsPanel::documentReplaced() +{ + setRootWatcher(); +} + +void ObjectsPanel::setRootWatcher() +{ + if (root_watcher) { + delete root_watcher; + } + root_watcher = nullptr; + + if (auto document = getDocument()) { + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + bool layers_only = prefs->getBool("/dialogs/objects/layers_only", false); + root_watcher = new ObjectWatcher(this, document->getRoot(), nullptr, layers_only); + layerChanged(getDesktop()->layerManager().currentLayer()); + selectionChanged(getSelection()); + } +} + +void ObjectsPanel::selectionChanged(Selection *selected) +{ + root_watcher->setSelectedBitRecursive(SELECTED_OBJECT, false); + + for (auto item : selected->items()) { + ObjectWatcher *watcher = nullptr; + // This both unpacks the tree, and populates lazy loading + for (auto &parent : item->ancestorList(true)) { + if (parent->getRepr() == root_watcher->getRepr()) { + watcher = root_watcher; + } else if (watcher) { + if ((watcher = watcher->findChild(parent->getRepr()))) { + if (auto row = watcher->getRow()) { + cleanDummyChildren(*row); + } + } + } + } + if (watcher) { + if (auto final_watcher = watcher->findChild(item->getRepr())) { + final_watcher->setSelectedBit(SELECTED_OBJECT, true); + _tree.expand_to_path(final_watcher->getTreePath()); + } else { + g_warning("Can't find final step in tree selection!"); + } + } else { + g_warning("Can't find a mid step in tree selection!"); + } + } +} + +/** + * Happens when the layer selected is changed. + * + * @param layer - The layer now selected + */ +void ObjectsPanel::layerChanged(SPObject *layer) +{ + root_watcher->setSelectedBitRecursive(LAYER_FOCUS_CHILD | LAYER_FOCUSED, false); + + if (!layer) return; + auto watcher = getWatcher(layer->getRepr()); + if (watcher && watcher != root_watcher) { + watcher->setSelectedBitChildren(LAYER_FOCUS_CHILD, true); + watcher->setSelectedBit(LAYER_FOCUSED, true); + } + _layer = layer; +} + + +/** + * Stylizes a button using the given icon name and tooltip + */ +Gtk::Button* ObjectsPanel::_addBarButton(char const* iconName, char const* tooltip, char const *action_name) +{ + Gtk::Button* btn = Gtk::manage(new Gtk::Button()); + auto child = Glib::wrap(sp_get_icon_image(iconName, GTK_ICON_SIZE_SMALL_TOOLBAR)); + child->show(); + btn->add(*child); + btn->set_relief(Gtk::RELIEF_NONE); + btn->set_tooltip_text(tooltip); + // Must use C API until GTK4. + gtk_actionable_set_action_name(GTK_ACTIONABLE(btn->gobj()), action_name); + return btn; +} + +/** + * Sets visibility of items in the tree + * @param iter Current item in the tree + */ +bool ObjectsPanel::toggleVisible(GdkEventButton* event, Gtk::TreeModel::Row row) +{ + if (SPItem* item = getItem(row)) { + if (event->state & GDK_SHIFT_MASK) { + // Toggle Visible for layers (hide all other layers) + if (auto desktop = getDesktop()) { + if (desktop->layerManager().isLayer(item)) { + desktop->layerManager().toggleLayerSolo(item); + DocumentUndo::done(getDocument(), _("Hide other layers"), ""); + } + } + } else { + item->setHidden(!row[_model->_colInvisible]); + // Use maybeDone so user can flip back and forth without making loads of undo items + DocumentUndo::maybeDone(getDocument(), "toggle-vis", _("Toggle item visibility"), ""); + } + } + return true; +} + +/** + * Sets sensitivity of items in the tree + * @param iter Current item in the tree + * @param locked Whether the item should be locked + */ +bool ObjectsPanel::toggleLocked(GdkEventButton* event, Gtk::TreeModel::Row row) +{ + if (SPItem* item = getItem(row)) { + if (event->state & GDK_SHIFT_MASK) { + // Toggle lock for layers (lock all other layers) + if (auto desktop = getDesktop()) { + if (desktop->layerManager().isLayer(item)) { + desktop->layerManager().toggleLockOtherLayers(item); + DocumentUndo::done(getDocument(), _("Lock other layers"), ""); + } + } + } else { + item->setLocked(!row[_model->_colLocked]); + // Use maybeDone so user can flip back and forth without making loads of undo items + DocumentUndo::maybeDone(getDocument(), "toggle-lock", _("Toggle item locking"), ""); + } + } + return true; +} + +/** + * Handles keyboard events + * @param event Keyboard event passed in from GDK + * @return Whether the event should be eaten (om nom nom) + */ +bool ObjectsPanel::_handleKeyEvent(GdkEventKey *event) +{ + auto desktop = getDesktop(); + if (!desktop) + return false; + + bool press = event->type == GDK_KEY_PRESS; + Gtk::AccelKey shortcut = Inkscape::Shortcuts::get_from_event(event); + switch (shortcut.get_key()) { + case GDK_KEY_Escape: + if (desktop->canvas) { + desktop->canvas->grab_focus(); + return true; + } + break; + + // space and return enter label editing mode; leave them for the tree to handle + case GDK_KEY_Return: + case GDK_KEY_space: + return false; + + case GDK_KEY_Alt_L: + case GDK_KEY_Alt_R: + _handleTransparentHover(press); + return false; + } + + return false; +} + +/** + * Handles mouse movements + * @param event Motion event passed in from GDK + * @returns Whether the event should be eaten. + */ +bool ObjectsPanel::_handleMotionEvent(GdkEventMotion* motion_event) +{ + if (_is_editing) return false; + + // Unhover any existing hovered row. + if (_hovered_row_ref) { + if (auto row = *_store->get_iter(_hovered_row_ref.get_path())) + row[_model->_colHover] = false; + } + // Allow this function to be called by LEAVE motion + if (!motion_event) { + _hovered_row_ref = Gtk::TreeModel::RowReference(); + _handleTransparentHover(false); + return false; + } + + Gtk::TreeModel::Path path; + Gtk::TreeViewColumn* col = nullptr; + int x, y; + if (_tree.get_path_at_pos((int)motion_event->x, (int)motion_event->y, path, col, x, y)) { + if (auto row = *_store->get_iter(path)) { + row[_model->_colHover] = true; + _hovered_row_ref = Gtk::TreeModel::RowReference(_store, path); + } + } + + _handleTransparentHover(motion_event->state & GDK_MOD1_MASK); + return false; +} + +void ObjectsPanel::_handleTransparentHover(bool enabled) +{ + SPItem *item = nullptr; + if (enabled && _hovered_row_ref) { + if (auto row = *_store->get_iter(_hovered_row_ref.get_path())) { + item = getItem(row); + } + } + + if (item == _solid_item) + return; + + // Set the target item, this prevents rerunning too. + _solid_item = item; + auto desktop = getDesktop(); + + // Reset all the items in the list. + for (auto &item : _translucent_items) { + Inkscape::DrawingItem *arenaitem = item->get_arenaitem(desktop->dkey); + arenaitem->setOpacity(SP_SCALE24_TO_FLOAT(item->style->opacity.value)); + } + _translucent_items.clear(); + + if (item) { + _generateTranslucentItems(getDocument()->getRoot()); + + for (auto &item : _translucent_items) { + Inkscape::DrawingItem *arenaitem = item->get_arenaitem(desktop->dkey); + arenaitem->setOpacity(0.2); + } + } +} + +/** + * Generate a new list of sibling items (recursive) + */ +void ObjectsPanel::_generateTranslucentItems(SPItem *parent) +{ + if (parent == _solid_item) + return; + if (parent->isAncestorOf(_solid_item)) { + for (auto &child: parent->children) { + if (auto item = dynamic_cast<SPItem *>(&child)) { + _generateTranslucentItems(item); + } + } + } else { + _translucent_items.push_back(parent); + } +} + +/** + * Handles mouse up events + * @param event Mouse event from GDK + * @return whether to eat the event (om nom nom) + */ +bool ObjectsPanel::_handleButtonEvent(GdkEventButton* event) +{ + auto selection = getSelection(); + if (!selection) + return false; + + Gtk::TreeModel::Path path; + Gtk::TreeViewColumn* col = nullptr; + int x, y; + if (_tree.get_path_at_pos((int)event->x, (int)event->y, path, col, x, y)) { + if (auto row = *_store->get_iter(path)) { + if (event->type == GDK_BUTTON_RELEASE) { + if (col == _eye_column) { + return toggleVisible(event, row); + } else if (col == _lock_column) { + return toggleLocked(event, row); + } + } + } + // Only the label reacts to clicks, nothing else, only need to test horz + Gdk::Rectangle r; + _tree.get_cell_area(path, *_name_column, r); + if (x < r.get_x() || x > (r.get_x() + r.get_width())) + return false; + + // This doesn't work, it might be being eaten. + if (event->type == GDK_2BUTTON_PRESS) { + _tree.set_cursor(path, *col, true); + _is_editing = true; + return true; + } + _is_editing = _is_editing && event->type == GDK_BUTTON_RELEASE; + auto row = *_store->get_iter(path); + if (!row) return false; + SPItem *item = getItem(row); + + if (!item) return false; + SPGroup *group = SP_GROUP(item); + + // Load the right click menu + const bool context_menu = event->type == GDK_BUTTON_PRESS && event->button == 3; + + // Select items on button release to not confuse drag (unless it's a right-click) + // Right-click selects too to set up the stage for context menu which frequently relies on current selection! + if (!_is_editing && (event->type == GDK_BUTTON_RELEASE || context_menu)) { + if (event->state & GDK_SHIFT_MASK && !selection->isEmpty()) { + // Select everything between this row and the last selected item + selection->setBetween(item); + } else if (event->state & GDK_CONTROL_MASK) { + selection->toggle(item); + } else if (group && group->layerMode() == SPGroup::LAYER) { + // if right-clicking on a layer, make it current for context menu actions to work correctly + if (context_menu) { + if (getDesktop()->layerManager().currentLayer() != item) { + getDesktop()->layerManager().setCurrentLayer(item, true); + } + } else { + selection->set(item); + } + } else if (!context_menu) { + selection->set(item); + } + + if (context_menu) { + ContextMenu *menu = new ContextMenu(getDesktop(), item, true); // true == hide menu item for opening this dialog! + menu->attach_to_widget(*this); // So actions work! + menu->show(); + menu->popup_at_pointer(nullptr); + } + return true; + } else { + // Remember the item for we are about to drag it! + current_item = item; + } + } + return false; +} + +/** + * Handle a successful item label edit + * @param path Tree path of the item currently being edited + * @param new_text New label text + */ +void ObjectsPanel::_handleEdited(const Glib::ustring& path, const Glib::ustring& new_text) +{ + _is_editing = false; + if (auto row = *_store->get_iter(path)) { + if (auto item = getItem(row)) { + if (!new_text.empty() && (!item->label() || new_text != item->label())) { + item->setLabel(new_text.c_str()); + DocumentUndo::done(getDocument(), _("Rename object"), ""); + } + } + } +} + +/** + * Take over the select row functionality from the TreeView, this is because + * we have two selections (layer and object selection) and require a custom + * method of rendering the result to the treeview. + */ +bool ObjectsPanel::select_row( Glib::RefPtr<Gtk::TreeModel> const & /*model*/, Gtk::TreeModel::Path const &path, bool /*sel*/ ) +{ + return true; +} + +/** + * Get the XML node which is associated with a row. Can be NULL for dummy children. + */ +Node *ObjectsPanel::getRepr(Gtk::TreeModel::Row const &row) const +{ + return row[_model->_colNode]; +} + +/** + * Get the item which is associated with a row. If getRepr(row) is not NULL, + * then this call is expected to also not be NULL. + */ +SPItem *ObjectsPanel::getItem(Gtk::TreeModel::Row const &row) const +{ + auto const this_const = const_cast<ObjectsPanel *>(this); + return dynamic_cast<SPItem *>(this_const->getObject(getRepr(row))); +} + +/** + * Return true if this row has dummy children. + */ +bool ObjectsPanel::hasDummyChildren(Gtk::TreeModel::Row const &row) const +{ + for (auto &c : row.children()) { + if (isDummy(c)) { + return true; + } + } + return false; +} + +/** + * If the given row has dummy children, remove them. + * @pre Eiter all, or no children are dummies + * @post If the function returns true, the row has no children + * @return False if there are children and they are not dummies + */ +bool ObjectsPanel::removeDummyChildren(Gtk::TreeModel::Row const &row) +{ + auto &children = row.children(); + if (!children.empty()) { + Gtk::TreeStore::iterator child = children[0]; + if (!isDummy(*child)) { + assert(!hasDummyChildren(row)); + return false; + } + + do { + assert(child->parent() == row); + assert(isDummy(*child)); + child = _store->erase(child); + } while (child && child->parent() == row); + } + return true; +} + +bool ObjectsPanel::cleanDummyChildren(Gtk::TreeModel::Row const &row) +{ + if (removeDummyChildren(row)) { + assert(row); + getWatcher(getRepr(row))->addChildren(getItem(row)); + return true; + } + return false; +} + +/** + * Signal handler for "drag-motion" + * + * Refuses drops into non-group items. + */ +bool ObjectsPanel::on_drag_motion(const Glib::RefPtr<Gdk::DragContext> &context, int x, int y, guint time) +{ + Gtk::TreeModel::Path path; + Gtk::TreeViewDropPosition pos; + + auto selection = getSelection(); + auto document = getDocument(); + + if (!selection || !document) + goto finally; + + _tree.get_dest_row_at_pos(x, y, path, pos); + + if (path) { + auto iter = _store->get_iter(path); + auto repr = getRepr(*iter); + auto obj = document->getObjectByRepr(repr); + + bool const drop_into = pos != Gtk::TREE_VIEW_DROP_BEFORE && // + pos != Gtk::TREE_VIEW_DROP_AFTER; + + // don't drop on self + if (selection->includes(obj)) { + goto finally; + } + + auto item = getItem(*iter); + + // only groups can have children + if (drop_into && !dynamic_cast<SPGroup const *>(item)) { + goto finally; + } + + context->drag_status(Gdk::ACTION_MOVE, time); + return false; + } + +finally: + // remove drop highlight + _tree.unset_drag_dest_row(); + context->drag_refuse(time); + return true; +} + +/** + * Signal handler for "drag-drop". + * + * Do the actual work of drag-and-drop. + */ +bool ObjectsPanel::on_drag_drop(const Glib::RefPtr<Gdk::DragContext> &context, int x, int y, guint time) +{ + Gtk::TreeModel::Path path; + Gtk::TreeViewDropPosition pos; + _tree.get_dest_row_at_pos(x, y, path, pos); + + if (!path) { + return true; + } + + auto drop_repr = getRepr(*_store->get_iter(path)); + bool const drop_into = pos != Gtk::TREE_VIEW_DROP_BEFORE && // + pos != Gtk::TREE_VIEW_DROP_AFTER; + + auto selection = getSelection(); + auto document = getDocument(); + if (selection && document) { + if (drop_into) { + selection->toLayer(document->getObjectByRepr(drop_repr)); + } else { + Node *after = (pos == Gtk::TREE_VIEW_DROP_BEFORE) ? drop_repr : drop_repr->prev(); + selection->toLayer(document->getObjectByRepr(drop_repr->parent()), false, after); + } + } + + on_drag_end(context); + return true; +} + +void ObjectsPanel::on_drag_start(const Glib::RefPtr<Gdk::DragContext> &context) +{ + auto selection = _tree.get_selection(); + selection->set_mode(Gtk::SELECTION_MULTIPLE); + selection->unselect_all(); + + auto obj_selection = getSelection(); + if (!obj_selection) + return; + + if (current_item && !obj_selection->includes(current_item)) { + // This means the item the user started to drag is not one that is selected + // So we'll deselect everything and start dragging this item instead. + auto watcher = getWatcher(current_item->getRepr()); + if (watcher) { + auto path = watcher->getTreePath(); + selection->select(path); + obj_selection->set(current_item); + } + } else { + // Drag all the items currently selected (multi-row) + for (auto item : obj_selection->items()) { + auto watcher = getWatcher(item->getRepr()); + if (watcher) { + auto path = watcher->getTreePath(); + selection->select(path); + } + } + } +} + +void ObjectsPanel::on_drag_end(const Glib::RefPtr<Gdk::DragContext> &context) +{ + auto selection = _tree.get_selection(); + selection->unselect_all(); + selection->set_mode(Gtk::SELECTION_NONE); + current_item = nullptr; +} + +} //namespace Dialogs +} //namespace UI +} //namespace Inkscape + +/* + Local Variables: + mode:c++ + c-file-style:"stroustrup" + c-file-offsets:((innamespace . 0)(inline-open . 0)(case-label . +)) + indent-tabs-mode:nil + fill-column:99 + End: +*/ +// vim: filetype=cpp:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:fileencoding=utf-8:textwidth=99 : |