KTextAddons

richtextbrowser.cpp
1/*
2 SPDX-FileCopyrightText: 2023-2025 Laurent Montel <montel@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.0-or-later
5*/
6
7#include "richtextbrowser.h"
8
9#include "widgets/textmessageindicator.h"
10#include <KCursor>
11#include <KLocalizedString>
12#include <KStandardActions>
13#include <QIcon>
14
15#include "config-textcustomeditor.h"
16#if HAVE_KTEXTADDONS_KIO_SUPPORT
17#include <KIO/KUriFilterSearchProviderActions>
18#endif
19#if HAVE_KTEXTADDONS_TEXT_TO_SPEECH_SUPPORT
20#include <TextEditTextToSpeech/TextToSpeech>
21#endif
22
23#include <KColorScheme>
24#include <QApplication>
25#include <QClipboard>
26#include <QContextMenuEvent>
27#include <QMenu>
28#include <QScrollBar>
29#include <QTextBlock>
30#include <QTextCursor>
31#include <QTextDocumentFragment>
32
33using namespace TextCustomEditor;
34class Q_DECL_HIDDEN RichTextBrowser::RichTextBrowserPrivate
35{
36public:
37 RichTextBrowserPrivate(RichTextBrowser *qq)
38 : q(qq)
39 , textIndicator(new TextCustomEditor::TextMessageIndicator(q))
40#if HAVE_KTEXTADDONS_KIO_SUPPORT
41 , webshortcutMenuManager(new KIO::KUriFilterSearchProviderActions(q))
42#endif
43 {
44 supportFeatures |= RichTextBrowser::Search;
45 supportFeatures |= RichTextBrowser::TextToSpeech;
46#if HAVE_KTEXTADDONS_KIO_SUPPORT
47 supportFeatures |= RichTextBrowser::AllowWebShortcut;
48#endif
49
50 // Workaround QTextEdit behavior: if the cursor points right after the link
51 // and start typing, the char format is kept. If user wants to write normal
52 // text right after the link, the only way is to move cursor at the next character
53 // (say for "<a>text</a>more text" the character has to be before letter "o"!)
54 // It's impossible if the whole document ends with a link.
55 // The same happens when text starts with a link: it's impossible to write normal text before it.
56 QObject::connect(q, &RichTextBrowser::cursorPositionChanged, q, [this]() {
57 QTextCursor c = q->textCursor();
58 if (c.charFormat().isAnchor() && !c.hasSelection()) {
60 // If we are at block start or end (and at anchor), we just set the "default" format
61 if (!c.atBlockEnd() && !c.atBlockStart() && !c.hasSelection()) {
62 QTextCursor probe = c;
63 // Otherwise, if the next character is not a link, we just grab it's format
65 if (!probe.charFormat().isAnchor()) {
66 fmt = probe.charFormat();
67 }
68 }
69 c.setCharFormat(fmt);
70 q->setTextCursor(c);
71 }
72 });
73 }
74
75 ~RichTextBrowserPrivate()
76 {
77 }
78
79 RichTextBrowser *const q;
80 TextCustomEditor::TextMessageIndicator *const textIndicator;
81 QTextDocumentFragment originalDoc;
82#if HAVE_KTEXTADDONS_KIO_SUPPORT
83 KIO::KUriFilterSearchProviderActions *const webshortcutMenuManager;
84#endif
86 QColor mReadOnlyBackgroundColor;
87 int mInitialFontSize;
88 bool customPalette = false;
89};
90
91RichTextBrowser::RichTextBrowser(QWidget *parent)
92 : QTextBrowser(parent)
93 , d(new RichTextBrowserPrivate(this))
94{
95 setAcceptRichText(true);
96 KCursor::setAutoHideCursor(this, true, false);
97 d->mInitialFontSize = font().pointSize();
98 regenerateColorScheme();
99}
100
101RichTextBrowser::~RichTextBrowser() = default;
102
103void RichTextBrowser::regenerateColorScheme()
104{
105 d->mReadOnlyBackgroundColor = KColorScheme(QPalette::Disabled, KColorScheme::View).background().color();
106 updateReadOnlyColor();
107}
108
109void RichTextBrowser::setDefaultFontSize(int val)
110{
111 d->mInitialFontSize = val;
112 slotZoomReset();
113}
114
115void RichTextBrowser::slotDisplayMessageIndicator(const QString &message)
116{
117 d->textIndicator->display(message);
118}
119
120void RichTextBrowser::contextMenuEvent(QContextMenuEvent *event)
121{
122 QMenu *popup = mousePopupMenu(event->pos());
123 if (popup) {
124 popup->exec(event->globalPos());
125 delete popup;
126 }
127}
128
129QMenu *RichTextBrowser::mousePopupMenu(QPoint pos)
130{
132 if (popup) {
133 const bool emptyDocument = document()->isEmpty();
134 if (!isReadOnly()) {
135 const QList<QAction *> actionList = popup->actions();
136 enum {
137 UndoAct,
138 RedoAct,
139 CutAct,
140 CopyAct,
141 PasteAct,
142 ClearAct,
143 SelectAllAct,
144 NCountActs
145 };
146 QAction *separatorAction = nullptr;
147 const int idx = actionList.indexOf(actionList[SelectAllAct]) + 1;
148 if (idx < actionList.count()) {
149 separatorAction = actionList.at(idx);
150 }
151 if (separatorAction) {
152 QAction *clearAllAction = KStandardActions::clear(this, &RichTextBrowser::slotUndoableClear, popup);
153 if (emptyDocument) {
154 clearAllAction->setEnabled(false);
155 }
156 popup->insertAction(separatorAction, clearAllAction);
157 }
158 }
159 if (searchSupport()) {
160 popup->addSeparator();
161 QAction *findAction = KStandardActions::find(this, &RichTextBrowser::findText, popup);
162 popup->addAction(findAction);
163 if (emptyDocument) {
164 findAction->setEnabled(false);
165 }
166 } else {
167 popup->addSeparator();
168 }
169
170#if HAVE_KTEXTADDONS_TEXT_TO_SPEECH_SUPPORT
171 if (!emptyDocument) {
172 QAction *speakAction = popup->addAction(i18n("Speak Text"));
173 speakAction->setIcon(QIcon::fromTheme(QStringLiteral("preferences-desktop-text-to-speech")));
174 connect(speakAction, &QAction::triggered, this, &RichTextBrowser::slotSpeakText);
175 }
176#endif
177#if HAVE_KTEXTADDONS_KIO_SUPPORT
178 if (webShortcutSupport() && textCursor().hasSelection()) {
179 popup->addSeparator();
180 const QString selectedText = textCursor().selectedText();
181 d->webshortcutMenuManager->setSelectedText(selectedText);
182 d->webshortcutMenuManager->addWebShortcutsToMenu(popup);
183 }
184#endif
185 addExtraMenuEntry(popup, pos);
186 return popup;
187 }
188 return nullptr;
189}
190
191void RichTextBrowser::slotSpeakText()
192{
193 QString text;
194 if (textCursor().hasSelection()) {
195 text = textCursor().selectedText();
196 } else {
197 text = toPlainText();
198 }
199 Q_EMIT say(text);
200}
201
202void RichTextBrowser::setWebShortcutSupport(bool b)
203{
204#if HAVE_KTEXTADDONS_KIO_SUPPORT
205 if (b) {
206 d->supportFeatures |= AllowWebShortcut;
207 } else {
208 d->supportFeatures = (d->supportFeatures & ~AllowWebShortcut);
209 }
210#else
211 Q_UNUSED(b);
212#endif
213}
214
215bool RichTextBrowser::webShortcutSupport() const
216{
217#if HAVE_KTEXTADDONS_KIO_SUPPORT
218 return d->supportFeatures & AllowWebShortcut;
219#else
220 return false;
221#endif
222}
223
224void RichTextBrowser::setSearchSupport(bool b)
225{
226 if (b) {
227 d->supportFeatures |= Search;
228 } else {
229 d->supportFeatures = (d->supportFeatures & ~Search);
230 }
231}
232
233bool RichTextBrowser::searchSupport() const
234{
235 return d->supportFeatures & Search;
236}
237
238void RichTextBrowser::setTextToSpeechSupport(bool b)
239{
240 if (b) {
241 d->supportFeatures |= TextToSpeech;
242 } else {
243 d->supportFeatures = (d->supportFeatures & ~TextToSpeech);
244 }
245}
246
247bool RichTextBrowser::textToSpeechSupport() const
248{
249 return d->supportFeatures & TextToSpeech;
250}
251
252void RichTextBrowser::addExtraMenuEntry(QMenu *menu, QPoint pos)
253{
254 Q_UNUSED(menu)
255 Q_UNUSED(pos)
256}
257
258void RichTextBrowser::slotUndoableClear()
259{
261 cursor.beginEditBlock();
262 cursor.movePosition(QTextCursor::Start);
264 cursor.removeSelectedText();
265 cursor.endEditBlock();
266}
267
268void RichTextBrowser::updateReadOnlyColor()
269{
270 if (isReadOnly()) {
271 QPalette p = palette();
272 p.setColor(QPalette::Base, d->mReadOnlyBackgroundColor);
273 p.setColor(QPalette::Window, d->mReadOnlyBackgroundColor);
274 setPalette(p);
275 }
276}
277
278static void richTextDeleteWord(QTextCursor cursor, QTextCursor::MoveOperation op)
279{
280 cursor.clearSelection();
282 cursor.removeSelectedText();
283}
284
285void RichTextBrowser::deleteWordBack()
286{
287 richTextDeleteWord(textCursor(), QTextCursor::PreviousWord);
288}
289
290void RichTextBrowser::deleteWordForward()
291{
292 richTextDeleteWord(textCursor(), QTextCursor::WordRight);
293}
294
295bool RichTextBrowser::event(QEvent *ev)
296{
297 if (ev->type() == QEvent::ShortcutOverride) {
298 auto e = static_cast<QKeyEvent *>(ev);
299 if (overrideShortcut(e)) {
300 e->accept();
301 return true;
302 }
303 } else if (ev->type() == QEvent::ApplicationPaletteChange) {
304 regenerateColorScheme();
305 }
306 return QTextEdit::event(ev);
307}
308
309void RichTextBrowser::wheelEvent(QWheelEvent *event)
310{
312 const int angleDeltaY{event->angleDelta().y()};
313 if (angleDeltaY > 0) {
314 zoomIn();
315 } else if (angleDeltaY < 0) {
316 zoomOut();
317 }
318 event->accept();
319 return;
320 }
322}
323
324bool RichTextBrowser::handleShortcut(QKeyEvent *event)
325{
326 const int key = event->key() | event->modifiers();
327
328 if (KStandardShortcut::copy().contains(key)) {
329 copy();
330 return true;
331 } else if (KStandardShortcut::paste().contains(key)) {
332 paste();
333 return true;
334 } else if (KStandardShortcut::cut().contains(key)) {
335 cut();
336 return true;
337 } else if (KStandardShortcut::undo().contains(key)) {
338 if (!isReadOnly()) {
339 undo();
340 }
341 return true;
342 } else if (KStandardShortcut::redo().contains(key)) {
343 if (!isReadOnly()) {
344 redo();
345 }
346 return true;
347 } else if (KStandardShortcut::deleteWordBack().contains(key)) {
348 if (!isReadOnly()) {
349 deleteWordBack();
350 }
351 return true;
352 } else if (KStandardShortcut::deleteWordForward().contains(key)) {
353 if (!isReadOnly()) {
354 deleteWordForward();
355 }
356 return true;
357 } else if (KStandardShortcut::backwardWord().contains(key)) {
359 cursor.movePosition(QTextCursor::PreviousWord);
361 return true;
362 } else if (KStandardShortcut::forwardWord().contains(key)) {
364 cursor.movePosition(QTextCursor::NextWord);
366 return true;
367 } else if (KStandardShortcut::next().contains(key)) {
369 bool moved = false;
370 qreal lastY = cursorRect(cursor).bottom();
371 qreal distance = 0;
372 do {
373 qreal y = cursorRect(cursor).bottom();
374 distance += qAbs(y - lastY);
375 lastY = y;
376 moved = cursor.movePosition(QTextCursor::Down);
377 } while (moved && distance < viewport()->height());
378
379 if (moved) {
380 cursor.movePosition(QTextCursor::Up);
382 }
384 return true;
385 } else if (KStandardShortcut::prior().contains(key)) {
387 bool moved = false;
388 qreal lastY = cursorRect(cursor).bottom();
389 qreal distance = 0;
390 do {
391 qreal y = cursorRect(cursor).bottom();
392 distance += qAbs(y - lastY);
393 lastY = y;
394 moved = cursor.movePosition(QTextCursor::Up);
395 } while (moved && distance < viewport()->height());
396
397 if (moved) {
398 cursor.movePosition(QTextCursor::Down);
400 }
402 return true;
403 } else if (KStandardShortcut::begin().contains(key)) {
405 cursor.movePosition(QTextCursor::Start);
407 return true;
408 } else if (KStandardShortcut::end().contains(key)) {
410 cursor.movePosition(QTextCursor::End);
412 return true;
413 } else if (KStandardShortcut::beginningOfLine().contains(key)) {
415 cursor.movePosition(QTextCursor::StartOfLine);
417 return true;
418 } else if (KStandardShortcut::endOfLine().contains(key)) {
420 cursor.movePosition(QTextCursor::EndOfLine);
422 return true;
423 } else if (searchSupport() && KStandardShortcut::find().contains(key)) {
424 Q_EMIT findText();
425 return true;
426 } else if (KStandardShortcut::pasteSelection().contains(key)) {
428 if (!text.isEmpty()) {
429 insertPlainText(text); // TODO: check if this is html? (MiB)
430 }
431 return true;
432 } else if (event == QKeySequence::DeleteEndOfLine) {
434 QTextBlock block = cursor.block();
435 if (cursor.position() == block.position() + block.length() - 2) {
437 } else {
439 }
440 cursor.removeSelectedText();
442 return true;
443 }
444
445 return false;
446}
447
448bool RichTextBrowser::overrideShortcut(QKeyEvent *event)
449{
450 const int key = event->key() | event->modifiers();
451
452 if (KStandardShortcut::copy().contains(key)) {
453 return true;
454 } else if (KStandardShortcut::paste().contains(key)) {
455 return true;
456 } else if (KStandardShortcut::cut().contains(key)) {
457 return true;
458 } else if (KStandardShortcut::undo().contains(key)) {
459 return true;
460 } else if (KStandardShortcut::redo().contains(key)) {
461 return true;
462 } else if (KStandardShortcut::deleteWordBack().contains(key)) {
463 return true;
464 } else if (KStandardShortcut::deleteWordForward().contains(key)) {
465 return true;
466 } else if (KStandardShortcut::backwardWord().contains(key)) {
467 return true;
468 } else if (KStandardShortcut::forwardWord().contains(key)) {
469 return true;
470 } else if (KStandardShortcut::next().contains(key)) {
471 return true;
472 } else if (KStandardShortcut::prior().contains(key)) {
473 return true;
474 } else if (KStandardShortcut::begin().contains(key)) {
475 return true;
476 } else if (KStandardShortcut::end().contains(key)) {
477 return true;
478 } else if (KStandardShortcut::beginningOfLine().contains(key)) {
479 return true;
480 } else if (KStandardShortcut::endOfLine().contains(key)) {
481 return true;
482 } else if (KStandardShortcut::pasteSelection().contains(key)) {
483 return true;
484 } else if (searchSupport() && KStandardShortcut::find().contains(key)) {
485 return true;
486 } else if (searchSupport() && KStandardShortcut::findNext().contains(key)) {
487 return true;
488 } else if (event->matches(QKeySequence::SelectAll)) { // currently missing in QTextEdit
489 return true;
490 } else if (event == QKeySequence::DeleteEndOfLine) {
491 return true;
492 }
493 return false;
494}
495
496void RichTextBrowser::keyPressEvent(QKeyEvent *event)
497{
498 const bool isControlClicked = event->modifiers() & Qt::ControlModifier;
499 const bool isShiftClicked = event->modifiers() & Qt::ShiftModifier;
500 if (handleShortcut(event)) {
501 event->accept();
502 } else if (event->key() == Qt::Key_Up && isControlClicked && isShiftClicked) {
503 moveLineUpDown(true);
504 event->accept();
505 } else if (event->key() == Qt::Key_Down && isControlClicked && isShiftClicked) {
506 moveLineUpDown(false);
507 event->accept();
508 } else if (event->key() == Qt::Key_Up && isControlClicked) {
509 moveCursorBeginUpDown(true);
510 event->accept();
511 } else if (event->key() == Qt::Key_Down && isControlClicked) {
512 moveCursorBeginUpDown(false);
513 event->accept();
514 } else {
516 }
517}
518
519int RichTextBrowser::zoomFactor() const
520{
521 int pourcentage = 100;
522 const QFont f = font();
523 if (d->mInitialFontSize != f.pointSize()) {
524 pourcentage = (f.pointSize() * 100) / d->mInitialFontSize;
525 }
526 return pourcentage;
527}
528
529void RichTextBrowser::slotZoomReset()
530{
531 QFont f = font();
532 if (d->mInitialFontSize != f.pointSize()) {
533 f.setPointSize(d->mInitialFontSize);
534 setFont(f);
535 }
536}
537
538void RichTextBrowser::moveCursorBeginUpDown(bool moveUp)
539{
543 cursor.clearSelection();
544 move.movePosition(QTextCursor::StartOfBlock);
546 move.endEditBlock();
548}
549
550void RichTextBrowser::moveLineUpDown(bool moveUp)
551{
555
556 const bool hasSelection = cursor.hasSelection();
557
558 if (hasSelection) {
559 move.setPosition(cursor.selectionStart());
560 move.movePosition(QTextCursor::StartOfBlock);
561 move.setPosition(cursor.selectionEnd(), QTextCursor::KeepAnchor);
563 } else {
564 move.movePosition(QTextCursor::StartOfBlock);
566 }
567 const QString text = move.selectedText();
568
570 move.removeSelectedText();
571
572 if (moveUp) {
573 move.movePosition(QTextCursor::PreviousBlock);
574 move.insertBlock();
575 move.movePosition(QTextCursor::Left);
576 } else {
577 move.movePosition(QTextCursor::EndOfBlock);
578 if (move.atBlockStart()) { // empty block
579 move.movePosition(QTextCursor::NextBlock);
580 move.insertBlock();
581 move.movePosition(QTextCursor::Left);
582 } else {
583 move.insertBlock();
584 }
585 }
586
587 int start = move.position();
588 move.clearSelection();
589 move.insertText(text);
590 int end = move.position();
591
592 if (hasSelection) {
593 move.setPosition(end);
594 move.setPosition(start, QTextCursor::KeepAnchor);
595 } else {
596 move.setPosition(start);
597 }
598 move.endEditBlock();
599
601}
602
603#include "moc_richtextbrowser.cpp"
QBrush background(BackgroundRole=NormalBackground) const
static void setAutoHideCursor(QWidget *w, bool enable, bool customEventFilter=false)
The RichTextBrowser class.
A widget that displays messages in the top-left corner.
Q_SCRIPTABLE Q_NOREPLY void start()
QString i18n(const char *text, const TYPE &arg...)
AKONADI_CALENDAR_EXPORT KCalendarCore::Event::Ptr event(const Akonadi::Item &item)
const QList< QKeySequence > & beginningOfLine()
const QList< QKeySequence > & begin()
const QList< QKeySequence > & cut()
const QList< QKeySequence > & undo()
const QList< QKeySequence > & next()
const QList< QKeySequence > & deleteWordBack()
const QList< QKeySequence > & find()
const QList< QKeySequence > & paste()
const QList< QKeySequence > & end()
const QList< QKeySequence > & copy()
const QList< QKeySequence > & backwardWord()
const QList< QKeySequence > & endOfLine()
const QList< QKeySequence > & forwardWord()
const QList< QKeySequence > & deleteWordForward()
const QList< QKeySequence > & findNext()
const QList< QKeySequence > & prior()
const QList< QKeySequence > & pasteSelection()
const QList< QKeySequence > & redo()
KOSM_EXPORT double distance(const std::vector< const OSM::Node * > &path, Coordinate coord)
virtual bool event(QEvent *event) override
QScrollBar * verticalScrollBar() const const
QWidget * viewport() const const
void triggerAction(SliderAction action)
void setEnabled(bool)
void setIcon(const QIcon &icon)
void triggered(bool checked)
const QColor & color() const const
QString text(Mode mode) const const
ShortcutOverride
Type type() const const
int pointSize() const const
void setPointSize(int pointSize)
QClipboard * clipboard()
Qt::KeyboardModifiers keyboardModifiers()
QIcon fromTheme(const QString &name)
const_reference at(qsizetype i) const const
qsizetype count() const const
qsizetype indexOf(const AT &value, qsizetype from) const const
QAction * addAction(const QIcon &icon, const QString &text, Functor functor, const QKeySequence &shortcut)
QAction * addSeparator()
QAction * exec()
Q_EMITQ_EMIT
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
void setColor(ColorGroup group, ColorRole role, const QColor &color)
int bottom() const const
bool isEmpty() const const
ControlModifier
int length() const const
int position() const const
bool isAnchor() const const
bool atBlockEnd() const const
bool atBlockStart() const const
void beginEditBlock()
QTextCharFormat charFormat() const const
void clearSelection()
bool hasSelection() const const
bool movePosition(MoveOperation operation, MoveMode mode, int n)
void removeSelectedText()
QString selectedText() const const
void setCharFormat(const QTextCharFormat &format)
void copy()
QMenu * createStandardContextMenu()
QRect cursorRect() const const
void cut()
void insertPlainText(const QString &text)
virtual void keyPressEvent(QKeyEvent *e) override
void paste()
bool isReadOnly() const const
void redo()
void setTextCursor(const QTextCursor &cursor)
QTextCursor textCursor() const const
QString toPlainText() const const
void undo()
virtual void wheelEvent(QWheelEvent *e) override
void zoomIn(int range)
void zoomOut(int range)
QList< QAction * > actions() const const
void insertAction(QAction *before, QAction *action)
This file is part of the KDE documentation.
Documentation copyright © 1996-2025 The KDE developers.
Generated on Fri Jan 3 2025 11:46:56 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.