diff --git a/widget/editor.go b/widget/editor.go index 05738996..2f54951a 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -4,9 +4,11 @@ package widget import ( "image" + "io" "math" "strings" "time" + "unicode/utf8" "gioui.org/f32" "gioui.org/gesture" @@ -31,6 +33,10 @@ type Editor struct { // Submit enabled translation of carriage return keys to SubmitEvents. // If not enabled, carriage returns are inserted as newlines in the text. Submit bool + // Mask replaces the visual display of each rune in the contents with the given rune. + // Newline characters are not masked. When non-zero, the unmasked contents + // are accessed by Len, Text, and SetText. + Mask rune eventKey int font text.Font @@ -39,6 +45,8 @@ type Editor struct { blinkStart time.Time focused bool rr editBuffer + maskReader maskReader + lastMask rune maxWidth int viewSize image.Point valid bool @@ -75,6 +83,49 @@ type Editor struct { prevEvents int } +type maskReader struct { + // rr is the underlying reader. + rr io.RuneReader + maskBuf [utf8.UTFMax]byte + // mask is the utf-8 encoded mask rune. + mask []byte + // overflow contains excess mask bytes left over after the last Read call. + overflow []byte +} + +func (m *maskReader) Reset(r io.RuneReader, mr rune) { + m.rr = r + n := utf8.EncodeRune(m.maskBuf[:], mr) + m.mask = m.maskBuf[:n] +} + +// Read reads from the underlying reader and replaces every +// rune with the mask rune. +func (m *maskReader) Read(b []byte) (n int, err error) { + for len(b) > 0 { + var replacement []byte + if len(m.overflow) > 0 { + replacement = m.overflow + } else { + var r rune + r, _, err = m.rr.ReadRune() + if err != nil { + break + } + if r == '\n' { + replacement = []byte{'\n'} + } else { + replacement = m.mask + } + } + nn := copy(b, replacement) + m.overflow = replacement[nn:] + n += nn + b = b[nn:] + } + return n, err +} + type EditorEvent interface { isEditorEvent() } @@ -272,6 +323,10 @@ func (e *Editor) Layout(gtx layout.Context, sh text.Shaper, font text.Font, size e.shaper = sh e.invalidate() } + if e.Mask != e.lastMask { + e.lastMask = e.Mask + e.invalidate() + } e.makeValid() e.processEvents(gtx) @@ -470,7 +525,12 @@ func (e *Editor) moveCoord(pos image.Point) { func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) { e.rr.Reset() - lines, _ := s.Layout(e.font, e.textSize, e.maxWidth, &e.rr) + var r io.Reader = &e.rr + if e.Mask != 0 { + e.maskReader.Reset(&e.rr, e.Mask) + r = &e.maskReader + } + lines, _ := s.Layout(e.font, e.textSize, e.maxWidth, r) dims := linesDimens(lines) for i := 0; i < len(lines)-1; i++ { // To avoid layout flickering while editing, assume a soft newline takes diff --git a/widget/editor_test.go b/widget/editor_test.go index 538e1ad9..ae696978 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -9,6 +9,7 @@ import ( "reflect" "testing" "testing/quick" + "unicode" "gioui.org/f32" "gioui.org/font/gofont" @@ -43,6 +44,28 @@ func TestEditor(t *testing.T) { assertCaret(t, e, 1, 4, len("æbc\naøå•")) e.Move(+1) assertCaret(t, e, 1, 4, len("æbc\naøå•")) + + // Ensure that password masking does not affect caret behavior + e.Move(-3) + assertCaret(t, e, 1, 1, len("æbc\na")) + e.Mask = '*' + e.Layout(gtx, cache, font, fontSize) + assertCaret(t, e, 1, 1, len("æbc\na")) + e.Move(-3) + assertCaret(t, e, 0, 2, len("æb")) + e.Mask = '\U0001F92B' + e.Layout(gtx, cache, font, fontSize) + e.moveEnd() + assertCaret(t, e, 0, 3, len("æbc")) + + // When a password mask is applied, it should replace all visible glyphs + for i, line := range e.lines { + for j, glyph := range line.Layout { + if glyph.Rune != e.Mask && !unicode.IsSpace(glyph.Rune) { + t.Errorf("glyph at (%d, %d) is unmasked rune %d", i, j, glyph.Rune) + } + } + } } // assertCaret asserts that the editor caret is at a particular line