giu icon indicating copy to clipboard operation
giu copied to clipboard

[Feature Request] InputTextMultiline() needs text wrapping, for example via Wrapped(true) like labels.

Open rasteric opened this issue 3 years ago • 13 comments

Related problem

Currently, InputTextMultiline() does not seem to be able to wrap text (unless I've missed something). To automatically wrap text is pretty much standard for such widgets nowadays. Is this a limitation of imgui?

Your request

InputTextMultiline().Wrapped(true) should make a multiline text entry to soft-wrap text on unicode whitespace characters.

Alternative solution

I'm trying to come up with a manual wrapping solution using g.CalcTextSize(s) but haven't gotten far yet. It's kind of complicated because of the editing, it shouldn't force the user to delete hard linefeeds when removing text.

Additional context

No response

rasteric avatar Jan 19 '22 16:01 rasteric

well, I always tought that there is a flag for it, but it isn't... I'll think about some alternative too.

gucio321 avatar Jan 19 '22 20:01 gucio321

I've found a crazy workaround that illustrates the fantastic flexibility of Go and this framework. First I define the word wrap callback:

// the word wrap callback parameterized for a multiline text input
cb := func(widget *g.InputTextMultilineWidget, data imgui.InputTextCallbackData) int32 {
	c := data.CursorPos()
	if c <= 0 {
		return 0
	}
	buff := data.Buffer()
	var nl int
	for nl = c - 1; nl > 0; nl-- {
		if buff[nl] == 10 {
			break
		}
	}
	w := g.GetWidgetWidth(widget)
	if TextWidth(string(buff[nl:c])) > w {
		for i := c - 1; i > nl; i-- {
			if buff[i] == 32 {
				buff[i] = 10
				data.MarkBufferModified()
				break
			}
		}
	}
	return 0
}

Now define the widget in advance instead of within the layout, and use the closure for defining its callback:

// prepare multiline input widgets and their word wrap callbacks
infoWidget := g.InputTextMultiline(&a.layoutBuff.info).Size(-1, 100).
	Flags(imgui.InputTextFlagsCallbackAlways)
cbInfo := func(data imgui.InputTextCallbackData) int32 {
	return cb(infoWidget, data)
}

Later, I render it like this: infoWidget.Callback(cbInfo)

The hack seems to work, although I've probably have missed some edge cases. It's hard-wrapping instead of soft-wrapping, but that's okay for my use case.

Helper function:

// TextWidth returns the width of the given text.
func TextWidth(s string) float32 {
	w, _ := g.CalcTextSize(s)
	return w
}

rasteric avatar Jan 20 '22 11:01 rasteric

@rasteric I'd suggest u to check/ask in https://github.com/ocornut/imgui if they have (or are planning to have) this feature, because IMO this feature should be in base repo instead implemented here.

gucio321 avatar Feb 02 '22 09:02 gucio321

Quick update, in case someone is interested. I found a workaround. It's hard because imgui and therefore GIU does not render any other newline-like unicode character as a new paragraph, only NEWLINE character 13. Therefore, it's not easy to distinguish between user-entered newline (persistent) and new lines introduced by the word breaking algorithms. Here is how I managed to do it nevertheless:

// WrapInputTextMultiline is a callback to wrap an input text multiline.
func WrapInputtextMultiline(widget *g.InputTextMultilineWidget, data imgui.InputTextCallbackData) int32 {
	switch data.EventFlag() {
	case imgui.InputTextFlagsCallbackCharFilter:
		c := data.EventChar()
		if c == '\n' {
			data.SetEventChar('\u07FF') // pivot character 2-bytes in UTF-8
		}

	case imgui.InputTextFlagsCallbackAlways:
		// 0. turn every pivot byte sequence into \r\n
		buff := data.Buffer()
		buff2 := []byte(strings.ReplaceAll(string(buff), "\u07FF", "\r\n"))
		for i := range buff {
			buff[i] = buff2[i]
		}
		data.MarkBufferModified()

		// 1. zap all newlines that are not preceeded by a CR (which was manually entered like above)
		cr := false
		for i, c := range buff {
			if c == 10 && !cr {
				buff[i] = 32
				data.MarkBufferModified()
			} else {
				if c == 13 {
					cr = true
				} else {
					cr = false
				}
			}
		}
		// 2. word break the whole buffer with the standard greedy algorithm
		nl := 0
		spc := 0
		w := g.GetWidgetWidth(widget)
		for i, c := range buff {
			if c == 10 {
				nl = i
			}
			if c == 32 {
				spc = i
			}
			if TextWidth(string(buff[nl:i])) > w && spc > 0 {
				buff[spc] = 10
				data.MarkBufferModified()
			}
		}
	}
	return 0
}

This needs to be wrapped into the callback, e.g. like this:

previewInfoWidget := g.InputTextMultiline(&fields.assetNote).Size(-1, -1).
		Flags(imgui.InputTextFlagsCallbackAlways | imgui.InputTextFlagsCallbackCharFilter)
previewInfo := func(data imgui.InputTextCallbackData) int32 {
		return WrapInputtextMultiline(previewInfoWidget, data)
	}
// possibly do something else, and later in the render loop:
previewInfoWidget.Callback(previewInfo)

You need to set both InputTextFlagsCallbackAlways and InputTextFlagsCallbackCharFilter.

How it works: I first introduce some pivot char of exactly 2 bytes in UTF-8, I chose the first one \u07FF. Then, we replace these 2 bytes in the buffer with the sequence \r\n. We cannot to this in SetEventChar because it only allows one rune! The \n in the sequence \r\n is rendered as newline, whereas the \r is invisible (not a space). We then can zap all \n that are not part of a sequence \r\n and apply the greedy word breaking algorithm on each callback in CallbackAlways.

The downside is that deleting user-input newline requires pressing backspace 2 times, which could be fixed by overriding the backspace functionality. It's barely tested and only on Linux so far. I feel like I've hacked into a Gibson (just kidding).

rasteric avatar May 04 '22 13:05 rasteric

@rasteric thanks for your code! it saved me now :smile: here is my small modification:

func WrapInputtextMultiline(widget *giu.InputTextMultilineWidget, data imgui.InputTextCallbackData) int32 {
	switch data.EventFlag() {
	case imgui.InputTextFlagsCallbackCharFilter:
		c := data.EventChar()
		if c == '\n' {
			data.SetEventChar('\u07FF') // pivot character 2-bytes in UTF-8
		}

	case imgui.InputTextFlagsCallbackAlways:
		// 0. turn every pivot byte sequence into \r\n
		buff := data.Buffer()
		buff2 := []byte(strings.ReplaceAll(string(buff), "\u07FF", "\r\n"))
		for i := range buff {
			buff[i] = buff2[i]
		}
		data.MarkBufferModified()

		// 1. zap all newlines that are not preceeded by a CR (which was manually entered like above)
		cr := false
		for i, c := range buff {
			if c == 10 && !cr {
				buff[i] = 32
				data.MarkBufferModified()
			} else {
				if c == 13 {
					cr = true
				} else {
					cr = false
				}
			}
		}
		// 2. word break the whole buffer with the standard greedy algorithm
		nl := 0
		spc := 0
		w := giu.GetWidgetWidth(widget)
		for i, c := range buff {
			if c == 10 {
				nl = i
			}
			if c == 32 {
				spc = i
			}
			if TextWidth(string(buff[nl:i])) > w {
				if spc > 0 {
					buff[spc] = 10
				} else {
					data.InsertBytes(len(buff)-1, []byte{10})
				}
				data.MarkBufferModified()
			}
		}
	}
	return 0
}

I was manibulating a veeeeery long strings, and the string had to be force-splited if no spaces found

gucio321 avatar Nov 20 '22 19:11 gucio321