1# vim: ts=8:sts=8:sw=8:noexpandtab
2#
3# This file is part of ReText
4# Copyright: 2014, 2017 Maurice van der Pot
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19import sys
20from markups import MarkdownMarkup, ReStructuredTextMarkup
21
22from PyQt5.QtGui import QTextCursor
23
24LARGER_THAN_ANYTHING = sys.maxsize
25
26class Row:
27	def __init__(self, block=None, text=None, separatorline=False, paddingchar=' '):
28		self.block = block
29		self.text = text
30		self.separatorline = separatorline
31		self.paddingchar = paddingchar
32
33	def __repr__(self):
34		return "<Row '%s' %s '%s'>" % (self.text, self.separatorline, self.paddingchar)
35
36def _getTableLines(doc, pos, markupClass):
37	startblock = doc.findBlock(pos)
38	editedlineindex = 0
39	offset = pos - startblock.position()
40
41	rows = [ Row(block = startblock,
42	             text = startblock.text()) ]
43
44	block = startblock.previous()
45	while any(c in block.text() for c in '+|'):
46		rows.insert(0, Row(block = block,
47		                   text = block.text()))
48		editedlineindex += 1
49		block = block.previous()
50
51	block = startblock.next()
52	while any(c in block.text() for c in '+|'):
53		rows.append(Row(block = block,
54		                text = block.text()))
55		block = block.next()
56
57	if markupClass == MarkdownMarkup:
58		for i, row in enumerate(rows):
59			if i == 1:
60				row.separatorline = True
61				row.paddingchar = '-'
62	elif markupClass == ReStructuredTextMarkup:
63		for row in rows:
64			if row.text.strip().startswith(('+-','+=')):
65				row.separatorline = True
66				row.paddingchar = row.text.strip()[1]
67				row.text = row.text.replace('+', '|')
68	return rows, editedlineindex, offset
69
70# Modify the edited line to put the table borders after the edition in their original positions.
71# It does not matter that this function changes the position of table borders before the edition,
72# because table editing mode only ever changes the table to the right of the cursor position.
73def _sortaUndoEdit(rows, editedlineindex, offset, editsize):
74	aftertext = rows[editedlineindex].text
75	if editsize < 0:
76		beforetext = ' ' * -editsize + aftertext
77	else:
78		beforetext = aftertext[:offset] + aftertext[offset + editsize:]
79
80	rows[editedlineindex].text = beforetext
81
82# Given text and the position of the n-th edge, returns n - 1
83def _getEdgeIndex(text, edge):
84	return text[:edge].count('|')
85
86def _determineRoomInCell(row, edge, edgeIndex, shrinking, startposition=0):
87
88	if len(row.text) > edge and row.text[edge] == '|' and \
89	   (not edgeIndex or _getEdgeIndex(row.text, edge) == edgeIndex):
90		clearance = 0
91		cellwidth = 0
92		afterContent = True
93		for i in range(edge - 1, startposition - 1, -1):
94			if row.text[i] == '|':
95				break
96			else:
97				if row.text[i] == row.paddingchar and afterContent:
98					clearance += 1
99				else:
100					afterContent = False
101				cellwidth += 1
102
103		if row.separatorline:
104			if shrinking:
105				# do not shrink separator cells below 3
106				room = max(0, cellwidth - 3)
107			else:
108				# start expanding the cell if only the space for a right-align marker is left
109				room = max(0, cellwidth - 1)
110		else:
111			room = clearance
112	else:
113		room = LARGER_THAN_ANYTHING
114
115	return room
116
117# Add an edit for a row to match the specified shift if it has an edge on the
118# specified position
119def _performShift(row, rowShift, edge, edgeIndex, shift):
120	editlist = []
121
122	# Any row that has an edge on the specified position and that doesn't
123	# already have edits that shift it 'shift' positions, will get an
124	# additional edit
125	if len(row.text) > edge and row.text[edge] == '|' and rowShift != shift and \
126	   (not edgeIndex or _getEdgeIndex(row.text, edge) == edgeIndex):
127		editsize = -(rowShift - shift)
128		rowShift = shift
129
130		# Insert one position further to the left on separator lines, because
131		# there may be a space (for esthetical reasons) or an alignment marker
132		# on the last position before the edge and that should stay next to the
133		# edge.
134		if row.separatorline:
135			edge -= 1
136
137		editlist.append((edge, editsize))
138
139	return editlist, rowShift
140
141# Finds the next edge position starting at offset in any row that is shifting.
142# Rows that are not shifting when we are searching for an edge starting at
143# offset, are rows that (upto offset) did not have any edges that aligned with
144# shifting edges on other rows.
145def _determineNextEdge(rows, rowShifts, offset):
146	nextedge = None
147	nextedgerow = None
148
149	for row, rowShift in zip(rows, rowShifts):
150		if rowShift != 0:
151			edge = row.text.find('|', offset)
152			if edge != -1 and (nextedge is None or edge < nextedge):
153				nextedge = edge
154				nextedgerow = row
155
156	return nextedge, _getEdgeIndex(nextedgerow.text, nextedge) if nextedge else None
157
158# Return a list of edits to be made in other lines to adapt the table lines to
159# a single edit in the edited line.
160def _determineEditLists(rows, editedlineindex, offset, editsize, alignWithAnyEdge):
161
162	# rowShift represents how much the characters on a line will shift as a
163	# result of the already collected edits to be made.
164	rowShifts = [0 for _ in rows]
165	rowShifts[editedlineindex] = editsize
166
167	editLists = [[] for _ in rows]
168
169	# Find the next edge position on the edited row
170	currentedge, currentedgeindex = _determineNextEdge(rows, rowShifts, offset)
171	firstEdge = True
172
173
174	while currentedge:
175
176		if alignWithAnyEdge:
177			# Ignore what column the edge belongs to
178			currentedgeindex = None
179
180		if editsize < 0:
181			# How much an edge shifts to the left depends on how much room
182			# there is in the cells on any row that shares this edge.
183			leastLeftShift = min((-rowShift + _determineRoomInCell(row, currentedge, currentedgeindex, True)
184				for row, rowShift in zip(rows, rowShifts)))
185
186			shift = max(editsize, -leastLeftShift)
187		else:
188			# When shifting right, determine how much only once based on how
189			# much the edited cell needs to expand
190			if firstEdge:
191				room = _determineRoomInCell(rows[editedlineindex], currentedge, currentedgeindex, False, offset)
192				shift = max(0, editsize - room)
193
194		for i, row in enumerate(rows):
195			editList, newRowShift = _performShift(row, rowShifts[i], currentedge, currentedgeindex, shift)
196			rowShifts[i] = newRowShift
197			editLists[i].extend(editList)
198
199		currentedge, currentedgeindex = _determineNextEdge(rows, rowShifts, currentedge + 1)
200		firstEdge = False
201
202	return editLists
203
204def _performEdits(cursor, rows, editLists, linewithoffset, offset):
205	cursor.joinPreviousEditBlock()
206	for i, (row, editList) in enumerate(zip(rows, editLists)):
207
208		for editpos, editsize in sorted(editList, reverse=True):
209
210			if i == linewithoffset:
211				editpos += offset
212
213			cursor.setPosition(row.block.position() + editpos)
214			if editsize > 0:
215				cursor.insertText(editsize * row.paddingchar)
216			else:
217				for _ in range(-editsize):
218					cursor.deletePreviousChar()
219	cursor.endEditBlock()
220
221def adjustTableToChanges(doc, pos, editsize, markupClass):
222	if markupClass in (MarkdownMarkup, ReStructuredTextMarkup):
223
224		# This is needed because in ReSt cells can span multiple columns
225		# and we can therefore not determine which edges in other rows
226		# are supposed to be aligned with the edges in the edited row.
227		alignWithAnyEdge = (markupClass == ReStructuredTextMarkup)
228
229		rows, editedlineindex, offset = _getTableLines(doc, pos, markupClass)
230
231		_sortaUndoEdit(rows, editedlineindex, offset, editsize)
232
233		editLists = _determineEditLists(rows, editedlineindex, offset, editsize, alignWithAnyEdge)
234
235		cursor = QTextCursor(doc)
236		_performEdits(cursor, rows, editLists, editedlineindex, editsize)
237
238def handleReturn(cursor, markupClass, newRow):
239	if markupClass not in (MarkdownMarkup, ReStructuredTextMarkup):
240		return False
241	positionInBlock = cursor.positionInBlock()
242	cursor.select(QTextCursor.SelectionType.BlockUnderCursor)
243	oldLine = cursor.selectedText().lstrip('\u2029')
244	if not ('| ' in oldLine or ' |' in oldLine):
245		cursor.setPosition(cursor.block().position() + positionInBlock)
246		return False
247	indent = 0
248	while oldLine[indent] in ' \t':
249		indent += 1
250	indentChars, oldLine = oldLine[:indent], oldLine[indent:]
251	newLine = ''.join('|' if c in '+|' else ' ' for c in oldLine).rstrip()
252	cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock)
253	if newRow and markupClass == MarkdownMarkup:
254		sepLine = ''.join(c if c in ' |' else '-' for c in oldLine)
255		cursor.insertText('\n' + indentChars + sepLine)
256	elif newRow:
257		sepLine = ''.join('+' if c in '+|' else '-' for c in oldLine)
258		cursor.insertText('\n' + indentChars + sepLine)
259	cursor.insertText('\n' + indentChars + newLine)
260	positionInBlock = min(positionInBlock, len(indentChars + newLine))
261	cursor.setPosition(cursor.block().position() + positionInBlock)
262	return True
263