前面学习了如何用Fyne 简单搭建一个GUI框架
而本网站的博客是使用Markdown进行创作的,理所当然的要写一个 基于Fyne的Markdown编辑器

程序框架搭建

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
"io"
"time"
)

type config struct {
Edit *widget.Entry // 输入区域
Preview *widget.RichText // 渲染区域
CurrentFile fyne.URI // 文件URI,即文件路径
MenuItem *fyne.MenuItem // Save菜单选项
}

var cfg config

func main() {
a := app.New()
w := a.NewWindow("Markdown编辑器")

w.SetContent(container.NewHSplit(cfg.makeUI()))
cfg.createMenu(w)
w.Resize(fyne.NewSize(800, 600))
w.CenterOnScreen()
w.ShowAndRun()
}

func (cfg *config) makeUI() (*widget.Entry, *widget.RichText) {
edit := widget.NewMultiLineEntry()
preview := widget.NewRichTextFromMarkdown("")

edit.OnChanged = preview.ParseMarkdown

cfg.Edit = edit
cfg.Preview = preview
return edit, preview
}
func (cfg *config) createMenu(win fyne.Window) {

}

在这里,使用了一个自定义的config结构,里面存储了会用到的一些属性,如输入的文本,输出渲染完成的Markdown文本,已经保存文件路径等。

使用了container.NewHSplit新容器,它以垂直的分割线左右平分页面。

makeUI中,将EditPreview等组件与cfg关联起来,而edit.OnChanged = preview.ParseMarkdown则是在编辑区发生变动的时候调用Markdown富文本渲染。

创建菜单

VSC菜单样式

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (cfg *config) createMenu(win fyne.Window) {
open := fyne.NewMenuItem("Open", cfg.open(win))
save := fyne.NewMenuItem("save", cfg.save(win))
cfg.MenuItem = save
save.Disabled = true

saveAs := fyne.NewMenuItem("save as", cfg.saveAsFunc(win))

// 菜单,相当于VSV上面一行中的一项
file := fyne.NewMenu("File", open, save, saveAs)
// 主菜单栏,相当于VSC上面那一行
menu := fyne.NewMainMenu(file)
win.SetMainMenu(menu)
}

func (cfg *config) save(win fyne.Window) func() {

}

func (cfg *config) open(win fyne.Window) func() {

}

func (cfg *config) saveAsFunc(win fyne.Window) func() {

}

这里将save 赋值给cfg是为了保证 没有打开文件和另存为文件前,用户无法进行保存操作,防止不可预知的错误出现

后续会通过cfg来设置允许保存。

通过回调函数分离实现功能。

实现对应功能

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
func (cfg *config) save(win fyne.Window) func() {
return func() {
// 注意此时已经有了保存路径,所以就不是调用 NewFileSave 了,而是通过URI来获取输出流并输出保存
writer, err := storage.Writer(cfg.CurrentFile)
if err != nil {
dialog.ShowError(err, win)
return
}
if writer == nil {
return
}
defer writer.Close()

writer.Write([]byte(cfg.Edit.Text))
}
}

func (cfg *config) open(win fyne.Window) func() {
return func() {
openDialog := dialog.NewFileOpen(func(closer fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}
if closer == nil {
return
}
defer closer.Close()

data, err := io.ReadAll(closer) // 读取操作
if err != nil {
dialog.ShowError(err, win)
return
}

cfg.Edit.SetText(string(data))// 将数据放到编辑区

cfg.CurrentFile = closer.URI()

win.SetTitle("Markdown编辑器-" + closer.URI().Name())

cfg.MenuItem.Disabled = false // 允许保存

}, win)

openDialog.Show()
}
}

func (cfg *config) saveAsFunc(win fyne.Window) func() {
return func() {
saveDialog := dialog.NewFileSave(func(closer fyne.URIWriteCloser, err error) {

if err != nil {
dialog.ShowError(err, win)
return
}
if closer == nil {
return
}
defer closer.Close() // defer可以理解为在函数的结尾会自动调用后面的函数,IO要记得及时关闭
closer.Write([]uint8(cfg.Edit.Text))

cfg.CurrentFile = closer.URI() // 获取输出的URI

win.SetTitle("Markdown编辑器-" + closer.URI().Name())// 更新标题

cfg.MenuItem.Disabled = false // 可以保存了

}, win)
saveDialog.SetFileName("未命名-" + time.DateOnly + ".md")
saveDialog.Show()
}
}

至此,基础的Markdown编辑器 的功能就完成了。

但是打开的文件不只有Markdown怎么办呢

为解决这个问题,可以使用storage.NewExtensionFileFilter来过滤掉不需要的文件。

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
...
var cfg config
+ var filter = storage.NewExtensionFileFilter([]string{".md", ".MD"})

func main() {
...
}
...
func (cfg *config) open(win fyne.Window) func() {
return func() {
...
+ openDialog.SetFilter(filter)

openDialog.Show()
}
}

func (cfg *config) saveAsFunc(win fyne.Window) func() {
return func() {
...
+ saveDialog.SetFilter(filter)

saveDialog.SetFileName("未命名-" + time.DateOnly + ".md")
saveDialog.Show()
}
}

虽然目前已经大体上完成,但还是有一些Bug

  • 可能保存的文件类型错误(缺省文件类型)
  • 都在一个文件夹内太过臃肿

对保存文件的类型进行检查

在saveAsFunc的方法内部添加一下语句:

main.go
1
2
3
4
5
6
7
8
9
10
11
...
defer closer.Close()

+if !strings.HasSuffix(strings.ToLower(closer.URI().String()), ".md") {
+ dialog.ShowInformation("文件类型错误", "必须是 .md 或 .MD", win)
+ return
+}

closer.Write([]uint8(cfg.Edit.Text))
...

分开存放在不同的文件下,便于管理

此次实操,我就将其分为

  • main.go
  • ui.go
  • config.go

注意,Golang虽然没有public,protected,private这样的修饰符,但能够通过更改首字母的大小写来更改可见性
若首字母是大写字母,则表示可以被其他包通过 import 来访问,相当于公开;而小写字母的话,则表示在本包内使用,相当于私有。
因此,在本节内容的分开存放源代码并不影响其变量和struct等的可见性,直接复制粘贴即可。因为他们都在 package main下面。

最后的源码

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
)

type config struct {
Edit *widget.Entry
Preview *widget.RichText
CurrentFile fyne.URI
MenuItem *fyne.MenuItem
}

var cfg config

var filter = storage.NewExtensionFileFilter([]string{".md", ".MD"})

func main() {
a := app.New()
w := a.NewWindow("Markdown编辑器")

w.SetContent(container.NewHSplit(cfg.makeUI()))
cfg.createMenu(w)
w.Resize(fyne.NewSize(800, 600))
w.CenterOnScreen()
w.ShowAndRun()
}

ui.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)

func (cfg *config) makeUI() (*widget.Entry, *widget.RichText) {
edit := widget.NewMultiLineEntry()
preview := widget.NewRichTextFromMarkdown("")

edit.OnChanged = preview.ParseMarkdown

cfg.Edit = edit
cfg.Preview = preview
return edit, preview
}

func (cfg *config) createMenu(win fyne.Window) {
open := fyne.NewMenuItem("Open", cfg.open(win))
save := fyne.NewMenuItem("save", cfg.save(win))
cfg.MenuItem = save
save.Disabled = true

saveAs := fyne.NewMenuItem("save as", cfg.saveAsFunc(win))

file := fyne.NewMenu("File", open, save, saveAs)

menu := fyne.NewMainMenu(file)
win.SetMainMenu(menu)
}

config.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package main

import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"io"
"strings"
"time"
)

func (cfg *config) save(win fyne.Window) func() {
return func() {
writer, err := storage.Writer(cfg.CurrentFile)
if err != nil {
dialog.ShowError(err, win)
return
}
if writer == nil {
return
}
defer writer.Close()

writer.Write([]byte(cfg.Edit.Text))
}
}

func (cfg *config) open(win fyne.Window) func() {
return func() {
openDialog := dialog.NewFileOpen(func(closer fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}
if closer == nil {
return
}
defer closer.Close()

data, err := io.ReadAll(closer)
if err != nil {
dialog.ShowError(err, win)
return
}

cfg.Edit.SetText(string(data))

cfg.CurrentFile = closer.URI()

win.SetTitle("Markdown编辑器-" + closer.URI().Name())

cfg.MenuItem.Disabled = false

}, win)
openDialog.SetFilter(filter)

openDialog.Show()
}
}

func (cfg *config) saveAsFunc(win fyne.Window) func() {
return func() {
saveDialog := dialog.NewFileSave(func(closer fyne.URIWriteCloser, err error) {

if err != nil {
dialog.ShowError(err, win)
return
}
if closer == nil {
return
}
defer closer.Close()

if !strings.HasSuffix(strings.ToLower(closer.URI().String()), ".md") {
dialog.ShowInformation("文件类型错误", "必须是 .md 或 .MD", win)
return
}

closer.Write([]uint8(cfg.Edit.Text))

cfg.CurrentFile = closer.URI()

win.SetTitle("Markdown编辑器-" + closer.URI().Name())

cfg.MenuItem.Disabled = false

}, win)

saveDialog.SetFilter(filter)

saveDialog.SetFileName("未命名-" + time.DateOnly + ".md")
saveDialog.Show()
}
}

打包

接下来使用下面的命令进行打包即可随时随地使用了。

1
$ fyne package -os windows -icon lnpbqc.png