View Issue Details

IDProjectCategoryView StatusLast Update
0015954ScribusUser Interfacepublic2026-09-10 12:00
ReporterJLuc Assigned To 
PrioritynormalSeverityminorReproducibilityhave not tried
Status newResolutionopen 
PlatformLinuxOSUbuntu OS Version18.04
Product Version1.5.5.svn 
Summary0015954: Improve link/unlink tools usability
DescriptionThe link or unlink tools are confusing because when using them, i never now whether it will work as intended.
There are several various possible behaviours and it's not possible to guess which one scribus developpers have choosen to code, because the UI doesnt show / give hints about it. 2 possible behaviours are : act on link TO clicked frame /or/ act on link FROM clicked frame.

A strong and clear hint would be to highlight what will be done before doing it. E.g. highlight to-be-created-or-deleted link

Proposition :
- Once link or unlink button is clicked, scribus enters link or unlink mode.
- In such a mode, scribus detects on which text frame the mouse cursor hovers and shows which link will be created or destroyed when the user clicks. So as to show which link would be created-or-destroyed, it could simply highlight it. Or mark with some "preview" color .
- when user clicks, the highlighted link is created or destroyed for real.
Tags#please_test
PatchNo

Relationships

related to 0015507 new "chain" frames, do not "link" them 
child of 0015724 new make the unlink tool a single click one 

Activities

buttar

2026-09-09 14:53

reporter   ~0054448

* Depends on `tc1.patch` (from 0015507) and `tcu1.patch` (from 0015724).
* Adds live feedback to Chain Text Frames mode.
-- Valid target: green tint/outline with dashed rounded `A ⇢ B` preview.
-- Insertion target: amber highlight/context; Before/After dialog retained.
-- Invalid target: red highlight + forbidden cursor.
-- Empty canvas/non-text targets keep the existing draw-and-chain behaviour.
* Reuses `FrameLinkRouter` geometry so previews match permanent rounded chain connectors.
* Keeps hover preview transient and selection-neutral.
* Fixes insertion-mode cleanup.
-- Successful insertion exits Chain mode.
-- Cancel keeps Chain mode active and restores the source selection.
-- Fixes the delayed-selection-signal imbalance on Cancel.
* Leaves chain ordering, multi-selection linking, undo semantics, serialization and `PageItem::link()` unchanged.
tcu2-chain-hover-preview-v1.03.patch (18,510 bytes)   
Index: scribus/appmodehelper.cpp
===================================================================
--- scribus/appmodehelper.cpp	(revision 27827)
+++ scribus/appmodehelper.cpp	(working copy)
@@ -651,7 +651,6 @@
 				(*a_scrActions)["itemConvertToPolygon"]->setEnabled(false);
 				(*a_scrActions)["itemConvertToTextFrame"]->setEnabled(false);
 				(*a_scrActions)["itemConvertToSymbolFrame"]->setEnabled(false);
-				(*a_scrActions)["toolsLinkTextFrame"]->setEnabled(true);
 				(*a_scrActions)["toolsUnlinkTextFrame"]->setEnabled(true);
 				(*a_scrActions)["toolsUnlinkTextFrameAndCutText"]->setEnabled(true);
 
@@ -667,8 +666,9 @@
 				(*a_scrActions)["toolsUnlinkTextFrame"]->setEnabled(false);
 				(*a_scrActions)["toolsUnlinkTextFrameAndCutText"]->setEnabled(false);
 			}
-//			(*a_scrActions)["toolsLinkTextFrame"]->setEnabled(!currItem->nextInChain());
-			(*a_scrActions)["toolsLinkTextFrame"]->setEnabled(true);
+			// A single source with an outgoing connection cannot accept another
+			// target. Multi-selection keeps its existing auto-chain behavior.
+			(*a_scrActions)["toolsLinkTextFrame"]->setEnabled(docSelectionCount > 1 || currItem->nextInChain() == nullptr);
 
 			//		if (doc->masterPageMode())
 			//			(*a_scrActions)["toolsLinkTextFrame"]->setEnabled(false);
Index: scribus/canvasmode_framelinks.cpp
===================================================================
--- scribus/canvasmode_framelinks.cpp	(revision 27827)
+++ scribus/canvasmode_framelinks.cpp	(working copy)
@@ -19,10 +19,12 @@
 #include <QApplication>
 #include <QButtonGroup>
 #include <QCheckBox>
+#include <QColor>
 #include <QCursor>
 #include <QEvent>
 #include <QMessageBox>
 #include <QMouseEvent>
+#include <QPainter>
 #include <QPainterPath>
 #include <QPoint>
 #include <QRect>
@@ -33,6 +35,7 @@
 #include "appmodes.h"
 #include "canvas.h"
 #include "fpoint.h"
+#include "framelinkrouter.h"
 #include "prefsmanager.h"
 #include "scribus.h"
 #include "scribusdoc.h"
@@ -57,6 +60,8 @@
 void CanvasMode_FrameLinks::drawControls(QPainter* p)
 {
 	commonDrawControls(p, false);
+	if (m_doc->appMode == modeLinkFrames)
+		drawLinkPreview(p);
 }
 
 void CanvasMode_FrameLinks::enterEvent(QEvent *e)
@@ -69,6 +74,7 @@
 
 void CanvasMode_FrameLinks::leaveEvent(QEvent *e)
 {
+	clearLinkPreview(false);
 }
 
 
@@ -76,6 +82,7 @@
 {
 //	qDebug() << "CanvasMode_FrameLinks::activate" << fromGesture;
 	CanvasMode::activate(fromGesture);
+	clearLinkPreview(false);
 
 	if (m_doc->m_Selection->count() >= 2)
 	{
@@ -118,6 +125,7 @@
 void CanvasMode_FrameLinks::deactivate(bool forGesture)
 {
 //	qDebug() << "CanvasMode_FrameLinks::deactivate" << forGesture;
+	clearLinkPreview(false);
 	m_view->setRedrawMarkerShown(false);
 	CanvasMode::deactivate(forGesture);
 }
@@ -155,6 +163,163 @@
 		m_view->HaveSelRect = true;
 		return;
 	}
+
+	if (m_doc->appMode == modeLinkFrames)
+		updateLinkPreview(m);
+}
+
+PageItem* CanvasMode_FrameLinks::linkTargetUnderCursor(QMouseEvent* m) const
+{
+	const unsigned SELECT_IN_GROUP = Qt::AltModifier;
+	const unsigned SELECT_BENEATH = Qt::ControlModifier;
+	PageItem* itemAbove = nullptr;
+
+	if ((m->modifiers() & SELECT_BENEATH) != 0)
+	{
+		PageItem* source = m_doc->ElemToLink;
+		if (source != nullptr)
+		{
+			FPoint mousePointDoc = m_canvas->globalToCanvas(m->globalPosition());
+			if (m_canvas->frameHitTest(QPointF(mousePointDoc.x(), mousePointDoc.y()), source) >= 0)
+				itemAbove = source;
+		}
+	}
+
+	return m_canvas->itemUnderCursor(m->globalPosition(), itemAbove, (m->modifiers() & SELECT_IN_GROUP));
+}
+
+CanvasMode_FrameLinks::LinkTargetState CanvasMode_FrameLinks::classifyLinkTarget(PageItem* source, PageItem* target) const
+{
+	if (source == nullptr || target == nullptr || !target->isTextFrame())
+		return LinkTargetState::None;
+
+	PageItem* targetChainTail = target;
+	while (targetChainTail->nextInChain() != nullptr)
+		targetChainTail = targetChainTail->nextInChain();
+
+	// Keep the preview classification aligned with the existing click path:
+	// a source cannot link into its own chain and cannot link while it already
+	// has an outgoing connection.
+	if (source == targetChainTail || source->nextInChain() != nullptr)
+		return LinkTargetState::Invalid;
+
+	if (target->prevInChain() != nullptr)
+		return LinkTargetState::Insert;
+
+	return LinkTargetState::Direct;
+}
+
+void CanvasMode_FrameLinks::updateLinkPreview(QMouseEvent* m)
+{
+	PageItem* target = linkTargetUnderCursor(m);
+	LinkTargetState state = classifyLinkTarget(m_doc->ElemToLink, target);
+
+	if (target == m_linkHoverTarget && state == m_linkHoverState)
+	{
+		if (state == LinkTargetState::Invalid)
+			m_view->setCursor(QCursor(Qt::ForbiddenCursor));
+		else
+			setModeCursor();
+		return;
+	}
+
+	m_linkHoverTarget = target;
+	m_linkHoverState = state;
+	if (state == LinkTargetState::Invalid)
+		m_view->setCursor(QCursor(Qt::ForbiddenCursor));
+	else
+		setModeCursor();
+	m_canvas->update();
+}
+
+void CanvasMode_FrameLinks::clearLinkPreview(bool resetCursor)
+{
+	const bool hadPreview = !m_linkHoverTarget.isNull() || m_linkHoverState != LinkTargetState::None;
+	m_linkHoverTarget.clear();
+	m_linkHoverState = LinkTargetState::None;
+	if (resetCursor)
+		setModeCursor();
+	if (hadPreview)
+		m_canvas->update();
+}
+
+void CanvasMode_FrameLinks::drawLinkTargetHighlight(QPainter* p, PageItem* target, const QColor& color) const
+{
+	if (target == nullptr)
+		return;
+
+	QColor tint(color);
+	tint.setAlpha(28);
+
+	p->save();
+	QPen pen(color, 2.0, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin);
+	pen.setCosmetic(true);
+	p->setPen(pen);
+	p->setBrush(tint);
+	p->setRenderHint(QPainter::Antialiasing, true);
+	p->setTransform(target->getTransform(), true);
+	p->drawRect(QRectF(0.0, 0.0, target->width(), target->height()));
+	p->restore();
+}
+
+void CanvasMode_FrameLinks::drawLinkPreview(QPainter* p)
+{
+	PageItem* source = m_doc->ElemToLink;
+	PageItem* target = m_linkHoverTarget.data();
+	if (source == nullptr || target == nullptr || m_linkHoverState == LinkTargetState::None)
+		return;
+
+	p->save();
+	p->resetTransform();
+	p->scale(m_canvas->scale(), m_canvas->scale());
+	p->translate(-m_doc->minCanvasCoordinate.x(), -m_doc->minCanvasCoordinate.y());
+
+	// Preview colors are semantic rather than document colors: green means a
+	// direct link can be committed, amber means insertion requires a choice,
+	// and red means the target is invalid.  Cursor/line-style differences keep
+	// the states distinguishable without relying on color alone.
+	const QColor validColor(46, 160, 67);
+	const QColor insertColor(191, 135, 0);
+	const QColor invalidColor(207, 34, 46);
+
+	if (m_linkHoverState == LinkTargetState::Direct)
+	{
+		FrameLinkRouter router(source, target, m_canvas->scale());
+		router.setSourceIsAlsoEntry(source->prevInChain() != nullptr);
+		router.setDestinationIsAlsoExit(target->nextInChain() != nullptr);
+		router.route();
+		router.drawOverlay(p, validColor, true);
+		drawLinkTargetHighlight(p, target, validColor);
+	}
+	else if (m_linkHoverState == LinkTargetState::Insert)
+	{
+		// An insertion has no single final route until the user chooses Before
+		// or After.  Show the target and its immediate chain context instead of
+		// drawing a misleading prospective arrow.
+		if (target->prevInChain() != nullptr)
+		{
+			PageItem* prev = target->prevInChain();
+			FrameLinkRouter incoming(prev, target, m_canvas->scale());
+			incoming.setSourceIsAlsoEntry(prev->prevInChain() != nullptr);
+			incoming.setDestinationIsAlsoExit(target->nextInChain() != nullptr);
+			incoming.route();
+			incoming.drawOverlay(p, insertColor);
+		}
+		if (target->nextInChain() != nullptr)
+		{
+			PageItem* next = target->nextInChain();
+			FrameLinkRouter outgoing(target, next, m_canvas->scale());
+			outgoing.setSourceIsAlsoEntry(target->prevInChain() != nullptr);
+			outgoing.setDestinationIsAlsoExit(next->nextInChain() != nullptr);
+			outgoing.route();
+			outgoing.drawOverlay(p, insertColor);
+		}
+		drawLinkTargetHighlight(p, target, insertColor);
+	}
+	else if (m_linkHoverState == LinkTargetState::Invalid)
+		drawLinkTargetHighlight(p, target, invalidColor);
+
+	p->restore();
 }
 
 void CanvasMode_FrameLinks::mousePressEvent(QMouseEvent *m)
@@ -186,11 +351,13 @@
 		return;
 	}
 	PageItem *currItem = nullptr, *bb = nullptr;
+	bool insertionCompleted = false;
 	switch (m_doc->appMode)
 	{
 		case modeLinkFrames:
 			if (m->button() != Qt::LeftButton)
 				break;
+			clearLinkPreview(false);
 			currItem = m_doc->ElemToLink;
 			if (currItem == nullptr)
 				break;
@@ -209,14 +376,27 @@
 					{
 						ScMessageBox msgBox(QMessageBox::Question, tr("Chaining Text Frames"),
 										   "<qt>" + tr("This frame will be inserted into an existing text chain. Where would you like to insert it?") + "<qt>");
-						QPushButton *cancelButton = msgBox.addButton(CommonStrings::tr_Cancel, QMessageBox::RejectRole);
+						msgBox.addButton(CommonStrings::tr_Cancel, QMessageBox::RejectRole);
 						QPushButton *beforeButton = msgBox.addButton(tr("Insert Before"), QMessageBox::AcceptRole);
 						QPushButton *afterButton = msgBox.addButton(tr("Insert After"), QMessageBox::AcceptRole);
 						msgBox.setDefaultBatchButton(afterButton);
+						// The modal dialog consumes the release event, so explicitly end
+						// the pressed state and handle mode completion below.
+						m_canvas->m_viewMode.m_MouseButtonPressed = false;
 						msgBox.exec();
-						if ((QPushButton *) msgBox.clickedButton() == cancelButton)
-							break;
-						if ((QPushButton *) msgBox.clickedButton() == beforeButton)
+						if (msgBox.clickedButton() != beforeButton && msgBox.clickedButton() != afterButton)
+						{
+							// Keep Chain mode active on cancel and restore the original
+							// source selection before releasing delayed selection signals.
+							m_doc->m_Selection->clear();
+							m_doc->m_Selection->addItem(currItem);
+							m_doc->m_Selection->delaySignalsOff();
+							clearLinkPreview(true);
+							m_view->DrawNew();
+							return;
+						}
+						insertionCompleted = true;
+						if (msgBox.clickedButton() == beforeButton)
 						{
 							if (currItem->prevInChain())
 								currItem->prevInChain()->unlink();
@@ -224,7 +404,7 @@
 							prev->unlink();
 							prev->link(currItem);
 						}
-						else if ((QPushButton *) msgBox.clickedButton() == afterButton)
+						else
 						{
 							if (bb->nextInChain() != nullptr)
 							{
@@ -295,6 +475,16 @@
 			else
 				m_doc->ElemToLink = nullptr;
 			m_doc->m_Selection->delaySignalsOff();
+			if (insertionCompleted)
+			{
+				// A completed Before/After insertion is a finished operation.
+				// Do not rely on mouseReleaseEvent(), which the modal dialog
+				// consumed; leave Chain mode even when Sticky Tools is enabled.
+				clearLinkPreview(false);
+				m_view->updateContents();
+				m_view->requestMode(submodePaintingDone);
+				return;
+			}
 			break;
 		case modeUnlinkFrames:
 			if (m->button() != Qt::LeftButton)
Index: scribus/canvasmode_framelinks.h
===================================================================
--- scribus/canvasmode_framelinks.h	(revision 27827)
+++ scribus/canvasmode_framelinks.h	(working copy)
@@ -19,11 +19,13 @@
 #define CANVAS_MODE_FRAMELINKS_H
 
 #include <QObject>
+#include <QPointer>
 #include <QTime>
 
 #include "canvasmode.h"
 #include "fpointarray.h"
 
+class QColor;
 class LineMove;
 class PageItem;
 class PageItem_TextFrame;
@@ -60,7 +62,21 @@
 // 	void setResizeCursor(int);
 
 private:
+	enum class LinkTargetState
+	{
+		None,
+		Direct,
+		Insert,
+		Invalid
+	};
+
 	inline bool GetItem(PageItem** pi);
+	PageItem* linkTargetUnderCursor(QMouseEvent* m) const;
+	LinkTargetState classifyLinkTarget(PageItem* source, PageItem* target) const;
+	void updateLinkPreview(QMouseEvent* m);
+	void clearLinkPreview(bool resetCursor = true);
+	void drawLinkPreview(QPainter* p);
+	void drawLinkTargetHighlight(QPainter* p, PageItem* target, const QColor& color) const;
 	void selectPage(QMouseEvent *m);
 	bool SeleItem(QMouseEvent *m);
 	void createContextMenu(PageItem *currItem, double mx, double my);
@@ -69,6 +85,8 @@
 	double m_Mxp {-1.0};
 	double m_Myp {-1.0};
 	ScribusMainWindow* m_ScMW {nullptr};
+	QPointer<PageItem> m_linkHoverTarget;
+	LinkTargetState m_linkHoverState {LinkTargetState::None};
 };
 
 
Index: scribus/framelinkrouter.cpp
===================================================================
--- scribus/framelinkrouter.cpp	(revision 27827)
+++ scribus/framelinkrouter.cpp	(working copy)
@@ -8,6 +8,12 @@
 ***************************************************************************/
 
 #include <cmath>
+
+#include <QColor>
+#include <QPainter>
+#include <QPainterPath>
+#include <QPolygonF>
+
 #include "framelinkrouter.h"
 
 #include "pageitem.h"
@@ -15,6 +21,74 @@
 #include "fpoint.h"
 #include "fpointarray.h"
 
+namespace
+{
+QPainterPath roundedOrthogonalPainterPath(const QVector<FPoint>& points, double radius, double scale, double kappa)
+{
+	QPainterPath path;
+	if (points.size() < 2)
+		return path;
+
+	// Match drawRoundedOrthogonalPath(): keep adjacent corner arcs from
+	// overlapping and drop rounding when the resulting radius is sub-pixel.
+	double r = radius;
+	for (int i = 0; i < points.size() - 1; ++i)
+	{
+		double segmentLength = fabs(points[i + 1].x() - points[i].x()) + fabs(points[i + 1].y() - points[i].y());
+		if (segmentLength * 0.5 < r)
+			r = segmentLength * 0.5;
+	}
+	if (r < 0.5 / scale)
+		r = 0.0;
+
+	path.moveTo(points.first().x(), points.first().y());
+	for (int i = 1; i < points.size(); ++i)
+	{
+		if (i >= points.size() - 1 || r <= 0.0)
+		{
+			path.lineTo(points[i].x(), points[i].y());
+			continue;
+		}
+
+		const FPoint& previous = points[i - 1];
+		const FPoint& current = points[i];
+		const FPoint& next = points[i + 1];
+		double inDx = current.x() - previous.x();
+		double inDy = current.y() - previous.y();
+		double outDx = next.x() - current.x();
+		double outDy = next.y() - current.y();
+		double inLength = fabs(inDx) + fabs(inDy);
+		double outLength = fabs(outDx) + fabs(outDy);
+
+		if (inLength < 1e-6 || outLength < 1e-6)
+		{
+			path.lineTo(current.x(), current.y());
+			continue;
+		}
+
+		double inNx = inDx / inLength;
+		double inNy = inDy / inLength;
+		double outNx = outDx / outLength;
+		double outNy = outDy / outLength;
+		double dot = inNx * outNx + inNy * outNy;
+		if (fabs(dot) > 0.99)
+		{
+			path.lineTo(current.x(), current.y());
+			continue;
+		}
+
+		double ax = current.x() - inNx * r;
+		double ay = current.y() - inNy * r;
+		double bx = current.x() + outNx * r;
+		double by = current.y() + outNy * r;
+		path.lineTo(ax, ay);
+		path.cubicTo(QPointF(ax + inNx * r * kappa, ay + inNy * r * kappa),
+		             QPointF(bx - outNx * r * kappa, by - outNy * r * kappa),
+		             QPointF(bx, by));
+	}
+	return path;
+}
+}
 
 FrameLinkRouter::FrameLinkRouter(PageItem* src, PageItem* dst, double scale)
 	: m_srcItem(src)
@@ -72,6 +146,66 @@
 }
 
 /**
+ * Draws the computed route as a transient QPainter overlay.
+ *
+ * The overlay deliberately reuses the router's computed waypoints instead of
+ * maintaining separate preview geometry.  A cosmetic pen keeps the preview
+ * readable at every zoom level.
+ */
+void FrameLinkRouter::drawOverlay(QPainter* painter, const QColor& color, bool dashed) const
+{
+	if (m_route.size() < 2)
+		return;
+
+	painter->save();
+	painter->setRenderHint(QPainter::Antialiasing, true);
+
+	if (isTouching())
+	{
+		double mx = (m_startPoint.x() + m_endPoint.x()) / 2.0;
+		double my = (m_startPoint.y() + m_endPoint.y()) / 2.0;
+		FPoint norm = edgeNormal(m_sourceGeometry, m_sourceEdge);
+		double sz = qBound(3.0, 6.0 / m_scale, 10.0);
+
+		QPolygonF marker;
+		marker << QPointF(mx + norm.x() * sz, my + norm.y() * sz)
+		       << QPointF(mx - norm.y() * sz * 0.5, my + norm.x() * sz * 0.5)
+		       << QPointF(mx + norm.y() * sz * 0.5, my - norm.x() * sz * 0.5);
+		painter->setPen(Qt::NoPen);
+		painter->setBrush(color);
+		painter->drawPolygon(marker);
+		painter->restore();
+		return;
+	}
+
+	QPen pen(color, dashed ? 1.5 : 1.0, dashed ? Qt::DashLine : Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin);
+	pen.setCosmetic(true);
+	painter->setPen(pen);
+	painter->setBrush(Qt::NoBrush);
+
+	QPainterPath path = roundedOrthogonalPainterPath(m_route, m_cornerRadius / m_scale, m_scale, m_kappa);
+	painter->drawPath(path);
+
+	const FPoint& tip = m_route.last();
+	const FPoint& from = m_route[m_route.size() - 2];
+	QTransform arrowTransform;
+	arrowTransform.translate(tip.x(), tip.y());
+	double angle = atan2(tip.y() - from.y(), tip.x() - from.x()) * (180.0 / M_PI);
+	arrowTransform.rotate(angle);
+	double sc = qBound(0.2, 0.5 * m_scale, 2.0) / m_scale;
+	arrowTransform.scale(sc, sc);
+
+	QPolygonF arrow;
+	arrow << QPointF(-12, 0) << QPointF(-15, -5) << QPointF(0, 0)
+	      << QPointF(-15, 5) << QPointF(-12, 0);
+	arrow = arrowTransform.map(arrow);
+	painter->setPen(Qt::NoPen);
+	painter->setBrush(color);
+	painter->drawPolygon(arrow);
+	painter->restore();
+}
+
+/**
  * Computes the exact start and end points on the chosen edges.
  *
  * For mid-chain frames (where a frame both receives and sends links),
Index: scribus/framelinkrouter.h
===================================================================
--- scribus/framelinkrouter.h	(revision 27827)
+++ scribus/framelinkrouter.h	(working copy)
@@ -14,7 +14,9 @@
 #include <QVector>
 
 #include "fpoint.h"
+class QColor;
 class PageItem;
+class QPainter;
 class ScPainter;
 
 
@@ -113,9 +115,12 @@
 	/// Compute frame geometries, pick edges, and build the route waypoints.
 	void route();
 
-	/// Draw the routed line (or chevron if touching) onto the painter.
+	/// Draw the routed line (or chevron if touching) onto the document painter.
 	void draw(ScPainter* painter) const;
 
+	/// Draw the same routed geometry as a transient canvas overlay.
+	void drawOverlay(QPainter* painter, const QColor& color, bool dashed = false) const;
+
 private:
 	// Bezier constant for approximating a quarter-circle arc with a cubic curve.
 	// Derived from (4/3) * tan(PI/8). Using this as the control point distance

buttar

2026-09-09 14:55

reporter   ~0054449

Here is a preview of what tcu2 does.

buttar

2026-09-10 12:00

reporter   ~0054452

[Depends on `tc1.patch` (from 0015507), `tcu1.patch` (from 0015724), and `tcu2.patch` (from earlier this ticket).]


* Adds toolbar/menu previews for text-chain actions.
  -- Multi-frame Chain: `[A] [B] [C]` → hover Chain → preview `A ⇢ B ⇢ C`.
  -- Break Text Chain: `[A] → B` → hover Break → red `A ─×→ B`.
  -- Break Text Chain and Split Text previews the connection that will be severed.
* Shares operation-plan logic between enablement, preview, and execution.
* Disables Chain when the current selection would add no connection.
* Refreshes Chain/Break action states immediately after topology changes.
* Keeps previews transient, selection-neutral, and cleared on menu/tool/document changes.
* Reuses `FrameLinkRouter` rounded geometry; leaves ordering, Undo, serialisation, and low-level link logic unchanged.
tcu3-text-chain-action-preview-v1.05.patch (28,665 bytes)   
Index: scribus/appmodehelper.cpp
===================================================================
--- scribus/appmodehelper.cpp	(revision 27827)
+++ scribus/appmodehelper.cpp	(working copy)
@@ -993,9 +993,19 @@
 		scmw->layerMenu->setEnabled(!inAnEditMode);
 	}
 
+	// Multi-selection Chain is a direct command: keep it enabled only when
+	// the exact sequential plan contains at least one connection to add.
+	if (docSelectionCount > 1 && (*a_scrActions)["toolsLinkTextFrame"]->isEnabled())
+		(*a_scrActions)["toolsLinkTextFrame"]->setEnabled(!doc->itemSelection_ChainTextFramePairs().isEmpty());
+
 	// Ordinary chain breaking is selection-driven: enable it only when the
 	// current selection identifies at least one connection to break.
 	(*a_scrActions)["toolsUnlinkTextFrame"]->setEnabled(doc->itemSelection_CanUnlinkTextFrames());
+
+	// Break-and-Split follows its own structural rule (incoming connection for
+	// non-head frames, outgoing connection for a chain head).
+	(*a_scrActions)["toolsUnlinkTextFrameAndCutText"]->setEnabled(
+		!doc->itemSelection_UnlinkTextFrameAndCutTextPairs().isEmpty());
 }
 
 void AppModeHelper::setModeActionsPerMode(int newMode)
Index: scribus/canvasmode_framelinks.cpp
===================================================================
--- scribus/canvasmode_framelinks.cpp	(revision 27827)
+++ scribus/canvasmode_framelinks.cpp	(working copy)
@@ -89,22 +89,19 @@
 		switch (m_doc->appMode)
 		{
 		case modeLinkFrames:
-			// if there are more than one text frames selected, link them and exit
-			for(int i = 0; i < m_doc->m_Selection->count(); i++)
-			{
-				PageItem* item1 = m_doc->m_Selection->itemAt(i);
-				PageItem* item2 = m_doc->m_Selection->itemAt(i+1);
-				if ((item1 != nullptr && item1->isTextFrame()) &&
-					(item2 != nullptr && item2->isTextFrame()) &&
-				     item1->canBeLinkedTo(item2))
-				{
-					item1->link(item2);
-				}
-			}
-			// now exit and return to the normal mode
+		{
+			// If more than one frame is selected, apply the same read-only chain
+			// plan used by action enablement and hover preview, then exit.
+			const auto pairs = m_doc->itemSelection_ChainTextFramePairs();
+			for (const auto& pair : pairs)
+				pair.first->link(pair.second);
+			// Return to normal mode before refreshing selection-dependent actions.
 			m_view->requestMode(modeNormal);
+			if (!pairs.isEmpty())
+				m_ScMW->HaveNewSel();
 			return;
 		}
+		}
 	}
 	m_canvas->m_viewMode.m_MouseButtonPressed = false;
 	m_canvas->resetRenderMode();
Index: scribus/canvasmode_normal.cpp
===================================================================
--- scribus/canvasmode_normal.cpp	(revision 27827)
+++ scribus/canvasmode_normal.cpp	(working copy)
@@ -17,8 +17,10 @@
 #include <QApplication>
 #include <QButtonGroup>
 #include <QCheckBox>
+#include <QColor>
 #include <QCursor>
 #include <QEvent>
+#include <QSet>
 #include <QMessageBox>
 #include <QMouseEvent>
 #include <QPainterPath>
@@ -37,6 +39,7 @@
 #include "canvasgesture_rulermove.h"
 #include "canvasmode_normal.h"
 #include "fileloader.h"
+#include "framelinkrouter.h"
 #include "fpoint.h"
 #include "iconmanager.h"
 #include "loadsaveplugin.h"
@@ -60,6 +63,45 @@
 #include "ui/pageselector.h"
 #include "undomanager.h"
 
+namespace
+{
+struct ChainPreviewSegment
+{
+	PageItem* source { nullptr };
+	PageItem* target { nullptr };
+	bool sourceIsAlsoEntry { false };
+	bool destinationIsAlsoExit { false };
+};
+
+QList<ChainPreviewSegment> chainSelectionPreviewSegments(ScribusDoc* doc)
+{
+	QList<ChainPreviewSegment> segments;
+	const QList<QPair<PageItem*, PageItem*>> pairs = doc->itemSelection_ChainTextFramePairs();
+	if (pairs.isEmpty())
+		return segments;
+
+	QSet<PageItem*> proposedSources;
+	QSet<PageItem*> proposedTargets;
+	for (const auto& pair : pairs)
+	{
+		proposedSources.insert(pair.first);
+		proposedTargets.insert(pair.second);
+	}
+
+	segments.reserve(pairs.size());
+	for (const auto& pair : pairs)
+	{
+		segments.append(ChainPreviewSegment {
+			pair.first,
+			pair.second,
+			pair.first->prevInChain() != nullptr || proposedTargets.contains(pair.first),
+			pair.second->nextInChain() != nullptr || proposedSources.contains(pair.second)
+		});
+	}
+
+	return segments;
+}
+}
 CanvasMode_Normal::CanvasMode_Normal(ScribusView* view) : CanvasMode(view), m_ScMW(view->m_ScMW) 
 {
 }
@@ -73,12 +115,101 @@
 void CanvasMode_Normal::drawControls(QPainter* p)
 {
 	// qDebug() << Q_FUNC_INFO;
+	if (m_view == m_ScMW->view && m_doc == m_ScMW->doc)
+	{
+		if (m_ScMW->breakTextChainPreviewActive())
+			drawBreakTextChainPreview(p);
+		else if (m_ScMW->breakTextChainAndSplitPreviewActive())
+			drawBreakTextChainAndSplitPreview(p);
+		else if (m_ScMW->chainTextFramePreviewActive())
+			drawChainTextFramePreview(p);
+	}
+
 	if (m_canvas->m_viewMode.operItemMoving)
 		drawOutline(p, m_objectDeltaPos.x(), m_objectDeltaPos.y());
 	else
 		drawSelection(p, !m_doc->drawAsPreview);
 }
 
+void CanvasMode_Normal::drawBreakTextChainPreview(QPainter* p)
+{
+	const QList<PageItem*> sources = m_doc->itemSelection_UnlinkTextFrameSources();
+	if (sources.isEmpty())
+		return;
+
+	const QColor removalColor(207, 34, 46);
+
+	p->save();
+	p->resetTransform();
+	p->scale(m_canvas->scale(), m_canvas->scale());
+	p->translate(-m_doc->minCanvasCoordinate.x(), -m_doc->minCanvasCoordinate.y());
+
+	for (PageItem* source : sources)
+	{
+		PageItem* target = source->nextInChain();
+		if (!target)
+			continue;
+
+		FrameLinkRouter router(source, target, m_canvas->scale());
+		router.setSourceIsAlsoEntry(source->prevInChain() != nullptr);
+		router.setDestinationIsAlsoExit(target->nextInChain() != nullptr);
+		router.route();
+		router.drawCrossedOverlay(p, removalColor);
+	}
+
+	p->restore();
+}
+
+void CanvasMode_Normal::drawBreakTextChainAndSplitPreview(QPainter* p)
+{
+	const QList<QPair<PageItem*, PageItem*>> pairs = m_doc->itemSelection_UnlinkTextFrameAndCutTextPairs();
+	if (pairs.isEmpty())
+		return;
+
+	const QColor removalColor(207, 34, 46);
+
+	p->save();
+	p->resetTransform();
+	p->scale(m_canvas->scale(), m_canvas->scale());
+	p->translate(-m_doc->minCanvasCoordinate.x(), -m_doc->minCanvasCoordinate.y());
+
+	for (const auto& pair : pairs)
+	{
+		FrameLinkRouter router(pair.first, pair.second, m_canvas->scale());
+		router.setSourceIsAlsoEntry(pair.first->prevInChain() != nullptr);
+		router.setDestinationIsAlsoExit(pair.second->nextInChain() != nullptr);
+		router.route();
+		router.drawCrossedOverlay(p, removalColor);
+	}
+
+	p->restore();
+}
+
+void CanvasMode_Normal::drawChainTextFramePreview(QPainter* p)
+{
+	const QList<ChainPreviewSegment> segments = chainSelectionPreviewSegments(m_doc);
+	if (segments.isEmpty())
+		return;
+
+	const QColor validColor(46, 160, 67);
+
+	p->save();
+	p->resetTransform();
+	p->scale(m_canvas->scale(), m_canvas->scale());
+	p->translate(-m_doc->minCanvasCoordinate.x(), -m_doc->minCanvasCoordinate.y());
+
+	for (const ChainPreviewSegment& segment : segments)
+	{
+		FrameLinkRouter router(segment.source, segment.target, m_canvas->scale());
+		router.setSourceIsAlsoEntry(segment.sourceIsAlsoEntry);
+		router.setDestinationIsAlsoExit(segment.destinationIsAlsoExit);
+		router.route();
+		router.drawOverlay(p, validColor, true);
+	}
+
+	p->restore();
+}
+
 void CanvasMode_Normal::enterEvent(QEvent *e)
 {
 	if (!m_canvas->m_viewMode.m_MouseButtonPressed)
Index: scribus/canvasmode_normal.h
===================================================================
--- scribus/canvasmode_normal.h	(revision 27827)
+++ scribus/canvasmode_normal.h	(working copy)
@@ -78,6 +78,9 @@
 	void handleMouseEnter(PageItem* currItem);
 	bool SeleItem(QMouseEvent *m);
 	void createContextMenu(PageItem *currItem, double mx, double my);
+	void drawBreakTextChainPreview(QPainter* p);
+	void drawBreakTextChainAndSplitPreview(QPainter* p);
+	void drawChainTextFramePreview(QPainter* p);
 
 	FPoint m_mousePressPoint;
 	FPoint m_mouseCurrentPoint;
Index: scribus/framelinkrouter.cpp
===================================================================
--- scribus/framelinkrouter.cpp	(revision 27827)
+++ scribus/framelinkrouter.cpp	(working copy)
@@ -206,6 +206,97 @@
 }
 
 /**
+ * Draws an emphasized crossed preview for a connection that will be removed.
+ *
+ * The routed line remains directional, while a centred cross makes the
+ * destructive action visible without relying on colour alone. Touching
+ * frames retain their compact chevron marker.
+ */
+void FrameLinkRouter::drawCrossedOverlay(QPainter* painter, const QColor& color) const
+{
+	if (m_route.size() < 2)
+		return;
+
+	painter->save();
+	painter->setRenderHint(QPainter::Antialiasing, true);
+
+	if (isTouching())
+	{
+		const double mx = (m_startPoint.x() + m_endPoint.x()) / 2.0;
+		const double my = (m_startPoint.y() + m_endPoint.y()) / 2.0;
+		const FPoint norm = edgeNormal(m_sourceGeometry, m_sourceEdge);
+		const double sz = qBound(3.0, 6.0 / m_scale, 10.0);
+
+		QPolygonF marker;
+		marker << QPointF(mx + norm.x() * sz, my + norm.y() * sz)
+		       << QPointF(mx - norm.y() * sz * 0.5, my + norm.x() * sz * 0.5)
+		       << QPointF(mx + norm.y() * sz * 0.5, my - norm.x() * sz * 0.5);
+		painter->setPen(Qt::NoPen);
+		painter->setBrush(color);
+		painter->drawPolygon(marker);
+	}
+	else
+	{
+		QPen pen(color, 2.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
+		pen.setCosmetic(true);
+		painter->setPen(pen);
+		painter->setBrush(Qt::NoBrush);
+		painter->drawPath(roundedOrthogonalPainterPath(m_route, m_cornerRadius / m_scale, m_scale, m_kappa));
+
+		const FPoint& tip = m_route.last();
+		const FPoint& from = m_route[m_route.size() - 2];
+		QTransform arrowTransform;
+		arrowTransform.translate(tip.x(), tip.y());
+		const double angle = atan2(tip.y() - from.y(), tip.x() - from.x()) * (180.0 / M_PI);
+		arrowTransform.rotate(angle);
+		const double sc = qBound(0.2, 0.5 * m_scale, 2.0) / m_scale;
+		arrowTransform.scale(sc, sc);
+
+		QPolygonF arrow;
+		arrow << QPointF(-12, 0) << QPointF(-15, -5) << QPointF(0, 0)
+		      << QPointF(-15, 5) << QPointF(-12, 0);
+		painter->setPen(Qt::NoPen);
+		painter->setBrush(color);
+		painter->drawPolygon(arrowTransform.map(arrow));
+	}
+
+	double totalLength = 0.0;
+	for (int i = 0; i < m_route.size() - 1; ++i)
+		totalLength += std::hypot(m_route[i + 1].x() - m_route[i].x(), m_route[i + 1].y() - m_route[i].y());
+
+	FPoint crossPoint((m_startPoint.x() + m_endPoint.x()) / 2.0, (m_startPoint.y() + m_endPoint.y()) / 2.0);
+	if (totalLength > 1e-6)
+	{
+		double remaining = totalLength * 0.5;
+		for (int i = 0; i < m_route.size() - 1; ++i)
+		{
+			const FPoint& a = m_route[i];
+			const FPoint& b = m_route[i + 1];
+			const double segmentLength = std::hypot(b.x() - a.x(), b.y() - a.y());
+			if (remaining <= segmentLength && segmentLength > 1e-6)
+			{
+				const double t = remaining / segmentLength;
+				crossPoint = FPoint(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t);
+				break;
+			}
+			remaining -= segmentLength;
+		}
+	}
+
+	const double crossRadius = 5.0 / m_scale;
+	QPen crossPen(color, 2.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
+	crossPen.setCosmetic(true);
+	painter->setPen(crossPen);
+	painter->setBrush(Qt::NoBrush);
+	painter->drawLine(QPointF(crossPoint.x() - crossRadius, crossPoint.y() - crossRadius),
+	                  QPointF(crossPoint.x() + crossRadius, crossPoint.y() + crossRadius));
+	painter->drawLine(QPointF(crossPoint.x() - crossRadius, crossPoint.y() + crossRadius),
+	                  QPointF(crossPoint.x() + crossRadius, crossPoint.y() - crossRadius));
+
+	painter->restore();
+}
+
+/**
  * Computes the exact start and end points on the chosen edges.
  *
  * For mid-chain frames (where a frame both receives and sends links),
Index: scribus/framelinkrouter.h
===================================================================
--- scribus/framelinkrouter.h	(revision 27827)
+++ scribus/framelinkrouter.h	(working copy)
@@ -121,6 +121,9 @@
 	/// Draw the same routed geometry as a transient canvas overlay.
 	void drawOverlay(QPainter* painter, const QColor& color, bool dashed = false) const;
 
+	/// Draw a crossed action preview for a connection that will be removed.
+	void drawCrossedOverlay(QPainter* painter, const QColor& color) const;
+
 private:
 	// Bezier constant for approximating a quarter-circle arc with a cubic curve.
 	// Derived from (4/3) * tan(PI/8). Using this as the control point distance
Index: scribus/scribus.cpp
===================================================================
--- scribus/scribus.cpp	(revision 27827)
+++ scribus/scribus.cpp	(working copy)
@@ -49,6 +49,7 @@
 #include <QMdiArea>
 #include <QMdiSubWindow>
 #include <QMessageBox>
+#include <QMenu>
 #include <QMouseEvent>
 #include <QMultiMap>
 #include <QPixmap>
@@ -387,6 +388,7 @@
 	createMenuBar();
 	initToolBars();
 	ScCore->pluginManager->setupPluginActions(this);
+	initTextChainActionPreviews();
 	ScCore->pluginManager->enableOnlyStartupPluginActions(this);
 	ScCore->pluginManager->languageChange();
 	if (primaryMainWindow)
@@ -600,6 +602,97 @@
 	connect(scrActions["toolsToolbarView"], SIGNAL(toggled(bool)), viewToolBar, SLOT(setVisible(bool)) );
 }
 
+void ScribusMainWindow::initTextChainActionPreviews()
+{
+	connect(modeToolBar, &ModeToolBar::chainTextFrameHoverChanged, this, [this](bool hovered)
+	{
+		QAction* action = scrActions["toolsLinkTextFrame"];
+		if (hovered)
+			updateTextChainActionPreview(action);
+		else if (m_textChainActionPreviewAction == action)
+			updateTextChainActionPreview(nullptr);
+	});
+	connect(modeToolBar, &ModeToolBar::breakTextChainHoverChanged, this, [this](bool hovered)
+	{
+		QAction* action = scrActions["toolsUnlinkTextFrame"];
+		if (hovered)
+			updateTextChainActionPreview(action);
+		else if (m_textChainActionPreviewAction == action)
+			updateTextChainActionPreview(nullptr);
+	});
+
+	// Plugin setup rebuilds parts of the Item menu; connect to the final submenu.
+	m_textChainActionPreviewMenu = scrMenuMgr->getLocalPopupMenu("TextLinking");
+	if (m_textChainActionPreviewMenu)
+	{
+		m_textChainActionPreviewMenu->setMouseTracking(true);
+		m_textChainActionPreviewMenu->setAttribute(Qt::WA_Hover, true);
+		m_textChainActionPreviewMenu->installEventFilter(this);
+		connect(m_textChainActionPreviewMenu, &QMenu::hovered,
+		        this, &ScribusMainWindow::updateTextChainActionPreview);
+		connect(m_textChainActionPreviewMenu, &QMenu::aboutToHide, this, [this]()
+		{
+			updateTextChainActionPreview(nullptr);
+		});
+	}
+
+	const QList<QAction*> previewActions {
+		scrActions["toolsLinkTextFrame"],
+		scrActions["toolsUnlinkTextFrame"],
+		scrActions["toolsUnlinkTextFrameAndCutText"]
+	};
+	for (QAction* action : previewActions)
+	{
+		connect(action, &QAction::triggered, this, [this]()
+		{
+			updateTextChainActionPreview(nullptr);
+		});
+		connect(action, &QAction::changed, this, [this, action]()
+		{
+			if (m_textChainActionPreviewAction == action && view)
+				view->updateCanvas();
+		});
+	}
+
+	connect(appModeHelper, &AppModeHelper::AppModeChanged, this, [this](int, int)
+	{
+		updateTextChainActionPreview(nullptr);
+	});
+}
+
+void ScribusMainWindow::updateTextChainActionPreview(QAction* action)
+{
+	if (action != scrActions.value("toolsLinkTextFrame")
+	    && action != scrActions.value("toolsUnlinkTextFrame")
+	    && action != scrActions.value("toolsUnlinkTextFrameAndCutText"))
+		action = nullptr;
+
+	if (m_textChainActionPreviewAction == action)
+		return;
+	m_textChainActionPreviewAction = action;
+	if (view)
+		view->updateCanvas();
+}
+
+bool ScribusMainWindow::chainTextFramePreviewActive() const
+{
+	const auto action = scrActions.value("toolsLinkTextFrame");
+	return m_textChainActionPreviewAction == action && HaveDoc && doc && view && action && action->isEnabled()
+	       && doc->m_Selection && doc->m_Selection->count() >= 2;
+}
+
+bool ScribusMainWindow::breakTextChainPreviewActive() const
+{
+	const auto action = scrActions.value("toolsUnlinkTextFrame");
+	return m_textChainActionPreviewAction == action && HaveDoc && doc && view && action && action->isEnabled();
+}
+
+bool ScribusMainWindow::breakTextChainAndSplitPreviewActive() const
+{
+	const auto action = scrActions.value("toolsUnlinkTextFrameAndCutText");
+	return m_textChainActionPreviewAction == action && HaveDoc && doc && view && action && action->isEnabled();
+}
+
 void ScribusMainWindow::setStyleSheet()
 {
 	QByteArray stylesheet;
@@ -1803,8 +1896,30 @@
 		currItem->update();
 }
 
-bool ScribusMainWindow::eventFilter( QObject* /*o*/, QEvent *e )
+bool ScribusMainWindow::eventFilter( QObject* o, QEvent *e )
 {
+	if (o == m_textChainActionPreviewMenu.data())
+	{
+		if (e->type() == QEvent::MouseMove || e->type() == QEvent::HoverMove)
+		{
+			const QPoint globalPos = QCursor::pos();
+			QPointer<QMenu> menu = m_textChainActionPreviewMenu;
+			// QMenu can retain its previous active action while crossing disabled
+			// entries. Re-evaluate after QMenu has processed this pointer move so
+			// its own hovered() signal cannot restore a stale preview.
+			QMetaObject::invokeMethod(this, [this, menu, globalPos]()
+			{
+				if (!menu || !menu->isVisible())
+					return;
+				const QPoint menuPos = menu->mapFromGlobal(globalPos);
+				QAction* action = menu->rect().contains(menuPos) ? menu->actionAt(menuPos) : nullptr;
+				updateTextChainActionPreview(action);
+			}, Qt::QueuedConnection);
+		}
+		else if (e->type() == QEvent::Leave || e->type() == QEvent::Hide)
+			updateTextChainActionPreview(nullptr);
+	}
+
 	if (e->type() == QEvent::ToolTip)
 		return (!m_prefsManager.appPrefs.displayPrefs.showToolTips);
 
@@ -2428,6 +2543,7 @@
 
 void ScribusMainWindow::newActWin(QMdiSubWindow *w)
 {
+	updateTextChainActionPreview(nullptr);
 	if (w == nullptr)
 	{
 		if (mdiArea->subWindowList().isEmpty())
Index: scribus/scribus.h
===================================================================
--- scribus/scribus.h	(revision 27827)
+++ scribus/scribus.h	(working copy)
@@ -43,12 +43,14 @@
 #include <QProcess>
 #include <QString>
 
+class QAction;
 class QCloseEvent;
 class QDragEnterEvent;
 class QDropEvent;
 class QEvent;
 class QKeyEvent;
 class QLabel;
+class QMenu;
 class QMdiArea;
 class QMdiSubWindow;
 class QQuickView;
@@ -140,6 +142,9 @@
 	*/
 	int initScMW(bool primaryMainWindow);
 	void setupMainWindow();
+	bool chainTextFramePreviewActive() const;
+	bool breakTextChainPreviewActive() const;
+	bool breakTextChainAndSplitPreviewActive() const;
 	int getScreenNumber() const;
 	QScreen* getScreen() const;
 	void getScreenPosition(int& xPos, int& yPos) const;
@@ -639,6 +644,8 @@
 	void addDefaultWindowMenuItems(); // addDefaultWindowMenuItems adds the basic Windows menu items, excluding the actual list of windows
 	void initStatusBar(); // setup the statusbar
 	void initToolBars(); // setup the toolbars
+	void initTextChainActionPreviews();
+	void updateTextChainActionPreview(QAction* action);
 	void setStyleSheet(); //set stylesheet for app
 	void initDefaultValues();
 	void initKeyboardShortcuts();
@@ -661,6 +668,8 @@
 	//bool m_arrowKeyDown;
 	/** @brief tells the undo mode */
 	bool m_objectSpecificUndo { false };
+	QPointer<QMenu> m_textChainActionPreviewMenu;
+	QPointer<QAction> m_textChainActionPreviewAction;
 
 	//CB: #8212: add overrideMasterPageSizing, however default to true for compatibility with other calls.. for now
 	void addNewPages(int wo, int where, int numPages, double height, double width, int orient, const QString& siz, bool mov, QStringList* basedOn = 0, bool overrideMasterPageSizing = true);
Index: scribus/scribusdoc.cpp
===================================================================
--- scribus/scribusdoc.cpp	(revision 27827)
+++ scribus/scribusdoc.cpp	(working copy)
@@ -31,6 +31,7 @@
 #include <QDialog>
 #include <QEventLoop>
 #include <QFile>
+#include <QHash>
 #include <QList>
 #include <QMessageBox>
 #include <QPainter>
@@ -17441,8 +17442,65 @@
 	m_documentFileName = documentFileName;
 }
 
-static QList<PageItem*> unlinkableTextFrameSources(Selection* itemSelection)
+QList<QPair<PageItem*, PageItem*>> ScribusDoc::itemSelection_ChainTextFramePairs(Selection* customSelection) const
 {
+	Selection* itemSelection = (customSelection != nullptr) ? customSelection : m_Selection;
+	QList<QPair<PageItem*, PageItem*>> pairs;
+	if (!itemSelection || itemSelection->count() < 2)
+		return pairs;
+
+	// Simulate links already planned earlier in the selection order so this
+	// read-only plan matches the sequential canBeLinkedTo()/link() loop.
+	QHash<PageItem*, PageItem*> proposedNext;
+	QHash<PageItem*, PageItem*> proposedPrev;
+	auto nextInChain = [&proposedNext](PageItem* item) -> PageItem*
+	{
+		return proposedNext.contains(item) ? proposedNext.value(item) : item->nextInChain();
+	};
+	auto prevInChain = [&proposedPrev](PageItem* item) -> PageItem*
+	{
+		return proposedPrev.contains(item) ? proposedPrev.value(item) : item->prevInChain();
+	};
+
+	for (int i = 0; i < itemSelection->count() - 1; ++i)
+	{
+		PageItem* source = itemSelection->itemAt(i);
+		PageItem* target = itemSelection->itemAt(i + 1);
+		if (!source || !target || !source->isTextFrame() || !target->isTextFrame())
+			continue;
+
+		bool canLink = nextInChain(source) == nullptr && prevInChain(target) == nullptr
+		               && !source->isAnnotation() && !target->isAnnotation()
+		               && source->OnMasterPage == target->OnMasterPage;
+		if (canLink)
+		{
+			for (PageItem* item = target; item != nullptr; item = nextInChain(item))
+			{
+				if (item == source)
+				{
+					canLink = false;
+					break;
+				}
+			}
+		}
+
+		if (!canLink)
+			continue;
+
+		pairs.append(qMakePair(source, target));
+		proposedNext.insert(source, target);
+		proposedPrev.insert(target, source);
+	}
+
+	return pairs;
+}
+
+QList<PageItem*> ScribusDoc::itemSelection_UnlinkTextFrameSources(Selection* customSelection) const
+{
+	Selection* itemSelection = (customSelection != nullptr) ? customSelection : m_Selection;
+	if (!itemSelection)
+		return {};
+
 	QList<PageItem*> textFrames;
 	QSet<PageItem*> selectedTextFrames;
 	textFrames.reserve(itemSelection->count());
@@ -17478,7 +17536,58 @@
 bool ScribusDoc::itemSelection_CanUnlinkTextFrames(Selection* customSelection) const
 {
 	Selection* itemSelection = (customSelection != nullptr) ? customSelection : m_Selection;
-	return itemSelection && !unlinkableTextFrameSources(itemSelection).isEmpty();
+	return itemSelection && !itemSelection_UnlinkTextFrameSources(itemSelection).isEmpty();
+}
+
+QList<QPair<PageItem*, PageItem*>> ScribusDoc::itemSelection_UnlinkTextFrameAndCutTextPairs(Selection* customSelection) const
+{
+	Selection* itemSelection = (customSelection != nullptr) ? customSelection : m_Selection;
+	QList<QPair<PageItem*, PageItem*>> pairs;
+	if (!itemSelection || itemSelection->count() == 0)
+		return pairs;
+
+	// Mirror unlinkWithText() structurally in selection order. Earlier planned
+	// breaks can change which incoming/outgoing connection a later frame sees.
+	QHash<PageItem*, PageItem*> proposedNext;
+	QHash<PageItem*, PageItem*> proposedPrev;
+	auto nextInChain = [&proposedNext](PageItem* item) -> PageItem*
+	{
+		return proposedNext.contains(item) ? proposedNext.value(item) : item->nextInChain();
+	};
+	auto prevInChain = [&proposedPrev](PageItem* item) -> PageItem*
+	{
+		return proposedPrev.contains(item) ? proposedPrev.value(item) : item->prevInChain();
+	};
+
+	for (int i = 0; i < itemSelection->count(); ++i)
+	{
+		PageItem* item = itemSelection->itemAt(i);
+		if (!item || !item->isTextFrame())
+			continue;
+
+		PageItem* prev = prevInChain(item);
+		PageItem* next = nextInChain(item);
+		PageItem* source = nullptr;
+		PageItem* target = nullptr;
+		if (prev)
+		{
+			source = prev;
+			target = item;
+		}
+		else if (next)
+		{
+			source = item;
+			target = next;
+		}
+		else
+			continue;
+
+		pairs.append(qMakePair(source, target));
+		proposedNext.insert(source, nullptr);
+		proposedPrev.insert(target, nullptr);
+	}
+
+	return pairs;
 }
 
 void ScribusDoc::itemSelection_UnlinkTextFrames(Selection* customSelection)
@@ -17487,7 +17596,7 @@
 	if (!itemSelection)
 		return;
 
-	const QList<PageItem*> sources = unlinkableTextFrameSources(itemSelection);
+	const QList<PageItem*> sources = itemSelection_UnlinkTextFrameSources(itemSelection);
 	if (sources.isEmpty())
 		return;
 
@@ -17528,6 +17637,8 @@
 	regionsChanged()->update(QRectF());
 	changed();
 	itemSelection->itemAt(0)->emitAllToGUI();
+	if (customSelection == nullptr && m_ScMW)
+		m_ScMW->HaveNewSel();
 }
 
 void ScribusDoc::itemSelection_UnWeld()
Index: scribus/scribusdoc.h
===================================================================
--- scribus/scribusdoc.h	(revision 27827)
+++ scribus/scribusdoc.h	(working copy)
@@ -35,6 +35,7 @@
 #include <QMap>
 #include <QObject>
 #include <QPixmap>
+#include <QPair>
 #include <QRectF>
 #include <QStringList>
 #include <QTimer>
@@ -1620,6 +1621,9 @@
 		void itemSelection_Rotate(double angle, Selection* customSelection = nullptr);
 		void itemSelection_DoHyphenate();
 		void itemSelection_DoDeHyphenate();
+		QList<QPair<PageItem*, PageItem*>> itemSelection_ChainTextFramePairs(Selection* customSelection = nullptr) const;
+		QList<QPair<PageItem*, PageItem*>> itemSelection_UnlinkTextFrameAndCutTextPairs(Selection* customSelection = nullptr) const;
+		QList<PageItem*> itemSelection_UnlinkTextFrameSources(Selection* customSelection = nullptr) const;
 		bool itemSelection_CanUnlinkTextFrames(Selection* customSelection = nullptr) const;
 		void itemSelection_UnlinkTextFrames(Selection* customSelection = nullptr);
 		void itemSelection_UnlinkTextFrameAndCutText(Selection* customSelection = nullptr);
Index: scribus/ui/modetoolbar.cpp
===================================================================
--- scribus/ui/modetoolbar.cpp	(revision 27827)
+++ scribus/ui/modetoolbar.cpp	(working copy)
@@ -100,7 +100,13 @@
 	this->addAction(m_ScMW->scrActions["toolsEditContents"]);
 	this->addAction(m_ScMW->scrActions["toolsEditWithStoryEditor"]);
 	this->addAction(m_ScMW->scrActions["toolsLinkTextFrame"]);
+	m_chainTextFrameButton = qobject_cast<QToolButton*>(widgetForAction(m_ScMW->scrActions["toolsLinkTextFrame"]));
+	if (m_chainTextFrameButton)
+		m_chainTextFrameButton->installEventFilter(this);
 	this->addAction(m_ScMW->scrActions["toolsUnlinkTextFrame"]);
+	m_breakTextChainButton = qobject_cast<QToolButton*>(widgetForAction(m_ScMW->scrActions["toolsUnlinkTextFrame"]));
+	if (m_breakTextChainButton)
+		m_breakTextChainButton->installEventFilter(this);
 	this->addAction(m_ScMW->scrActions["toolsMeasurements"]);
 	this->addAction(m_ScMW->scrActions["toolsCopyProperties"]);
 	this->addAction(m_ScMW->scrActions["toolsEyeDropper"]);
@@ -119,6 +125,25 @@
 	delete calPop;
 }
 
+bool ModeToolBar::eventFilter(QObject* watched, QEvent* event)
+{
+	if (watched == m_chainTextFrameButton)
+	{
+		if (event->type() == QEvent::Enter)
+			emit chainTextFrameHoverChanged(true);
+		else if (event->type() == QEvent::Leave || event->type() == QEvent::Hide)
+			emit chainTextFrameHoverChanged(false);
+	}
+	else if (watched == m_breakTextChainButton)
+	{
+		if (event->type() == QEvent::Enter)
+			emit breakTextChainHoverChanged(true);
+		else if (event->type() == QEvent::Leave || event->type() == QEvent::Hide)
+			emit breakTextChainHoverChanged(false);
+	}
+	return ScToolBar::eventFilter(watched, event);
+}
+
 void ModeToolBar::newCalValues()
 {
 	m_ScMW->doc->itemToolPrefs().calligraphicPenAngle = Angle->value();
Index: scribus/ui/modetoolbar.h
===================================================================
--- scribus/ui/modetoolbar.h	(revision 27827)
+++ scribus/ui/modetoolbar.h	(working copy)
@@ -62,6 +62,10 @@
 	int ValCount { 0 };
 	double *ShapeVals { nullptr };
 
+signals:
+	void chainTextFrameHoverChanged(bool hovered);
+	void breakTextChainHoverChanged(bool hovered);
+
 public slots:
 	void newCalValues();
 	void GetPolyProps();
@@ -70,8 +74,12 @@
 	void languageChange();
 		
 protected:
+	bool eventFilter(QObject* watched, QEvent* event) override;
+
 	AutoformButtonGroup* autoFormButtonGroup { nullptr };
 	QMenu* insertPolygonButtonMenu { nullptr };
+	QToolButton* m_chainTextFrameButton { nullptr };
+	QToolButton* m_breakTextChainButton { nullptr };
 	QAction* idInsertPolygonButtonMenu { nullptr };
 	ScribusMainWindow* m_ScMW { nullptr };
 	QWidget *propWidget { nullptr };

Issue History

Date Modified Username Field Change
2019-11-16 11:10 JLuc New Issue
2019-11-16 12:13 JLuc Description Updated
2019-11-16 12:15 JLuc Description Updated
2019-11-16 12:17 JLuc Description Updated
2019-11-16 17:08 ale Relationship added child of 0015724
2026-09-09 14:53 buttar Note Added: 0054448
2026-09-09 14:53 buttar File Added: tcu2-chain-hover-preview-v1.03.patch
2026-09-09 14:53 buttar Tag Attached: #please_test
2026-09-09 14:55 buttar Note Added: 0054449
2026-09-09 14:55 buttar File Added: chain-frame-colour-preview.mp4
2026-09-10 07:14 JLuc Relationship added related to 0015507
2026-09-10 12:00 buttar Note Added: 0054452
2026-09-10 12:00 buttar File Added: tcu3-text-chain-action-preview-v1.05.patch