Markdown 编辑器

此项目的要求和最终效果

能够支持中文, 程序窗口左半边写 markdown, 右边则显示富文本, 也就是现在我们 Vs code 里面的 Preview(プレビュー)

解决语言问题

Fyne 默认字体不支持中文, 我们可以复制一下下面的测试文本, 基于我们第一个案例, 会发现是乱码。

package main

import (
  "fmt"

  "fyne.io/fyne/v2/app"
  "fyne.io/fyne/v2/widget"
)

func main() {
  a:= app.New()
  w:= a.NewWindow("让我们一起来学习Go语言吧!")

  w.SetContent(widget.NewLabel("让我们一起来学习Go语言吧!"))
  w.ShowAndRun()

  fmt.Println("close!")
}

在项目文件夹中(必要文件), 找到字体文件(NotoSansHans-Regular.ttf)或者亦可以用你喜好的字体。将项目文件夹中的theme.goutil.go复制到项目目录下。

然后, 在项目中添加如下代码:

customFont := fyne.NewStaticResource("NotoSansHans.ttf", loadFont("NotoSansHans-Regular.ttf"))
a.Settings().SetTheme(&myTheme{font: customFont})

只需要改loadFont内的内容即可。

实战 Struct 和 Receiver

package main

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

type config struct {
 EditWidget    *widget.Entry
 PreviewWidget *widget.RichText
 CurrentFile   fyne.URI
 MenuItem  *fyne.MenuItem
}

正式写代码

container.NewHSplit对应的文档: https://docs.fyne.io/api/v2.4/container/split.html

URI URL 辨析

  1. URI 是一个用于标识资源的字符串, 它可以是资源的名称、位置或两者的结合。URI 的概念是一个广义的概念, 包含了两类子集:URL 和 URN。

  2. URL(统一资源定位符) URL 是 URI 的一个子集, 用于指定资源的位置。URL 不仅标识资源, 还提供了定位资源的方法。URL 通常包括以下部分:

方案(Scheme): 例如 http、https、ftp 等,表示访问资源的协议。 主机(Host): 资源所在的主机名或 IP 地址。 路径(Path): 资源在主机上的具体位置。 查询参数(Query Param): 附加的参数信息,用于传递额外的数据。 片段(Fragment): 资源的一部分,例如网页中的一个锚点。

举一个例子(锚点): https://ja.wikipedia.org/wiki/Go_(プログラミング言語)#interface

案例

https://example.com/path/to/resource?query=param#fragment

URI例子

file:///Users/username/Documents/example.md

代码是最终成品

Filter(筛选器)

代码

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

提示:

func HasSuffix(s, suffix string) bool {
 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

表示方法

s[len(s)-len(suffix):]

假设 s 为example.md

放在不同的文件中

目录说明

  myapp/
|-- main.go
|-- test.go
  

main.gotest.go第一行

package main

main.go更多代码

func main() {
    ... ...
}

附注: 只要在同一个包, 顶层变量和函数就可以互相自由访问。

生成一个图标

随意地找一个生成器即可, 格式要求 Icon.png

最终目录

  myapp/
|-- main.go
|-- ui.go
|-- config.go
  

这里只有 Import 相关的内容

config.go

import (
 "io"
 "strings"

 "fyne.io/fyne/v2"
 "fyne.io/fyne/v2/dialog"
 "fyne.io/fyne/v2/storage"
)

ui.go

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

main.go

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

综合代码

main.go

type config struct {
 EditWidget *widget.Entry
 PreviewWidget *widget.RichText
 CurrentFile fyne.URI
 MenuItem *fyne.MenuItem
 BaseTitle string
}

var cfg config

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

func main() {
 a := app.New()

 customFont := fyne.NewStaticResource("NotoSansHans.ttf", loadFont("NotoSansHans-Regular.ttf"))
 a.Settings().SetTheme(&myTheme{font: customFont})

 w := a.NewWindow("Markdown编辑器")
 cfg.BaseTitle = "Markdown编辑器"

 edit, preview := cfg.makeUI()
 cfg.createMenu(w)

 w.SetContent(container.NewHSplit(edit, preview))
 w.Resize(fyne.Size{Width: 800, Height: 600})
 w.CenterOnScreen()
 w.ShowAndRun()
}

ui.go

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("")

 cfg.EditWidget = edit
 cfg.PreviewWidget = preview

 edit.OnChanged = preview.ParseMarkdown

 return edit, preview
}

func (cfg *config) createMenu(win fyne.Window){
 open := fyne.NewMenuItem("打开...", cfg.openFunc(win))
 save := fyne.NewMenuItem("保存", cfg.saveFunc(win))
 cfg.MenuItem = save
 cfg.MenuItem.Disabled = true

    saveAs := fyne.NewMenuItem("另存为...", cfg.saveAsFunc(win))

 fileMenu := fyne.NewMenu("文件", open, save, saveAs)

 menu := fyne.NewMainMenu(fileMenu)

 win.SetMainMenu(menu)
}

config.go

package main

import (
 "io/ioutil"
 "strings"

 "fyne.io/fyne/v2"
 "fyne.io/fyne/v2/dialog"
 "fyne.io/fyne/v2/storage"
)

func (cfg *config) saveAsFunc(win fyne.Window) func(){
 return func(){
  saveDialog := dialog.NewFileSave(func(write fyne.URIWriteCloser, err error){
   if err != nil{
    dialog.ShowError(err, win)
    return 
   }
   
   if write == nil{
    return 
   }

   if !strings.HasSuffix(strings.ToLower(write.URI().String()),".md"){
    dialog.ShowInformation("错误", "必须是.md扩展名", win)
    return
   }

   write.Write([]uint8(cfg.EditWidget.Text))

   cfg.CurrentFile = write.URI()

   defer write.Close()

   win.SetTitle(cfg.BaseTitle + "-" + write.URI().Name())
   cfg.MenuItem.Disabled = false
  },win)
  saveDialog.SetFileName("未命名.md")
  saveDialog.SetFilter(filter)
  saveDialog.Show()
 }
}

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

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

   defer read.Close()

   cfg.EditWidget.SetText(string(data))

   cfg.CurrentFile = read.URI()

   win.SetTitle(cfg.BaseTitle + "-" + read.URI().Name())

   cfg.MenuItem.Disabled = false
  },win)
  openDialog.SetFilter(filter)
  openDialog.Show()
 }
}

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

   write.Write([]byte(cfg.EditWidget.Text))
   defer write.Close()
  }
 }
}

测试文件

对应成品代码

package main

import (
 "testing"

 "fyne.io/fyne/v2/container"
 "fyne.io/fyne/v2/test"
)

func Test_makeUI(t *testing.T){
 var testCfg config
 edit, preview := testCfg.makeUI()

 test.Type(edit, "Gofjer")

 if preview.String() != "Gofjer"{
  t.Error("Failed!!!!")
 }
}

打包项目

参考此文档即可: https://docs.fyne.io/started/packaging.html